From 9fb9bfc81ace6ef616503cf28335bfbdcd8c3286 Mon Sep 17 00:00:00 2001 From: Fahmi Harun <34875577+kuker24@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:48:46 +0700 Subject: [PATCH 1/8] ops: prepare fail-safe START admission canary Default START_DB_ADMISSION_LIMIT to 0, isolate api8 on a replacement app mount, and log upstream identity for control-versus-canary reads. --- app/api/exam_answer_sync.py | 9 +- app/api/exams.py | 595 +++++++++--------- app/core/cache.py | 92 ++- app/core/security.py | 9 +- app/core/singleflight.py | 56 ++ app/core/start_db_admission.py | 270 ++++++++ app/main.py | 4 + app/middleware/seb_validation.py | 22 +- app/middleware/security.py | 4 + app/middleware/start_admission_bind.py | 15 + app/services/answer_runtime_buffer.py | 13 +- app/services/answer_sync_service.py | 25 +- app/services/exam_service.py | 304 +++++++-- app/utils/apk_validation.py | 18 +- docker-compose.canary-api8.yml | 19 + docker/nginx.canary-stage0.api8.line | 1 + docker/nginx.production.conf | 4 +- tests/test_cache_fill_singleflight.py | 383 +++++++++++ tests/test_canary_api8_isolation.py | 122 ++++ tests/test_exam_hotpath_query_fanout.py | 160 +++++ tests/test_exam_start_live_projection.py | 221 +++++++ tests/test_exam_start_recovery_guard.py | 22 +- tests/test_exam_start_session_state.py | 526 ++++++++++++++++ .../test_exam_start_transaction_lifecycle.py | 388 ++++++++++++ tests/test_start_unified_admission.py | 342 ++++++++++ 25 files changed, 3204 insertions(+), 420 deletions(-) create mode 100644 app/core/singleflight.py create mode 100644 app/core/start_db_admission.py create mode 100644 app/middleware/start_admission_bind.py create mode 100644 docker-compose.canary-api8.yml create mode 100644 docker/nginx.canary-stage0.api8.line create mode 100644 tests/test_cache_fill_singleflight.py create mode 100644 tests/test_canary_api8_isolation.py create mode 100644 tests/test_exam_hotpath_query_fanout.py create mode 100644 tests/test_exam_start_live_projection.py create mode 100644 tests/test_exam_start_session_state.py create mode 100644 tests/test_exam_start_transaction_lifecycle.py create mode 100644 tests/test_start_unified_admission.py diff --git a/app/api/exam_answer_sync.py b/app/api/exam_answer_sync.py index f6d3b69..e43f335 100644 --- a/app/api/exam_answer_sync.py +++ b/app/api/exam_answer_sync.py @@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import noload from app.core.security import AuthenticatedUser, get_current_user_hot_path from app.database import get_db, get_db_read @@ -46,7 +47,9 @@ async def get_session_answers( ): """Get all saved answers for a session for restore on refresh.""" result = await db.execute( - select(ExamSession).where( + select(ExamSession) + .options(noload("*")) + .where( ExamSession.id == session_id, ExamSession.user_id == current_user.id, ) @@ -59,7 +62,9 @@ async def get_session_answers( if session.status not in ["in_progress", "active"]: return {"answers": {}, "session_status": session.status} - result = await db.execute(select(Answer).where(Answer.session_id == session_id)) + result = await db.execute( + select(Answer).options(noload("*")).where(Answer.session_id == session_id) + ) answers = result.scalars().all() answer_dict: Dict[int, object] = {} diff --git a/app/api/exams.py b/app/api/exams.py index 70c0421..b470347 100644 --- a/app/api/exams.py +++ b/app/api/exams.py @@ -89,6 +89,7 @@ normalize_role, ) from app.core.session_recovery import RECOVERY_CATEGORY_ADMIN, evaluate_session_recovery +from app.core.start_db_admission import bind_start_admission, start_db_segment from app.config import settings from app.core.feature_flags import require_feature_enabled from app.core.rate_limiter import RateLimiters, check_rate_limit @@ -387,23 +388,24 @@ async def _ensure_exam_start_option_integrity(db: AsyncSession, exam_id: int) -> str(exc), ) - orphaned_check = await db.execute( - select(Question.id, Question.question_text, Question.question_type) - .outerjoin(QuestionOption, Question.id == QuestionOption.question_id) - .where( - Question.exam_id == exam_id, - QuestionOption.id == None, - or_( - Question.question_type.in_(["multiple_choice", "true_false"]), - and_( - Question.question_type == "multiple_choice_complex", - func.coalesce(Question.pgk_type, "checkbox") != "table_validation", + async with start_db_segment("integrity"): + orphaned_check = await db.execute( + select(Question.id, Question.question_text, Question.question_type) + .outerjoin(QuestionOption, Question.id == QuestionOption.question_id) + .where( + Question.exam_id == exam_id, + QuestionOption.id == None, + or_( + Question.question_type.in_(["multiple_choice", "true_false"]), + and_( + Question.question_type == "multiple_choice_complex", + func.coalesce(Question.pgk_type, "checkbox") != "table_validation", + ), ), - ), + ) + .group_by(Question.id, Question.question_text, Question.question_type) ) - .group_by(Question.id, Question.question_text, Question.question_type) - ) - orphaned_questions = orphaned_check.all() + orphaned_questions = orphaned_check.all() if orphaned_questions: orphaned_ids = [str(q[0]) for q in orphaned_questions] logger.error( @@ -1984,6 +1986,7 @@ async def join_exam_by_token( # Find exam by token (keep query lightweight under burst join traffic). result = await db.execute( select(Exam, User.role.label("creator_role")) + .options(noload("*")) .join(User, User.id == Exam.creator_id) .where(Exam.access_token == token) ) @@ -2326,313 +2329,307 @@ async def start_exam_session( detail="Hanya peserta ujian yang dapat mengikuti ujian" ) - # Validate SEB - await validate_seb_headers(request, exam_id, db, require_seb=True) - exam_service = ExamService(db) - exam = await exam_service.get_exam_with_settings(exam_id) + async with bind_start_admission(request): + await validate_seb_headers(request, exam_id, db, require_seb=True) + exam_service = ExamService(db) + async with start_db_segment("main"): + exam = await exam_service.get_exam_start_projection(exam_id) - if not exam: - raise HTTPException(status_code=404, detail="Ujian tidak ditemukan") - - await _ensure_exam_start_option_integrity(db, exam_id) + if not exam: + raise HTTPException(status_code=404, detail="Ujian tidak ditemukan") - if not exam.is_published: - raise HTTPException(status_code=400, detail="Ujian belum dipublikasikan") + await _ensure_exam_start_option_integrity(db, exam_id) - now = datetime.now(timezone.utc) - if now < exam.start_time: - raise HTTPException(status_code=400, detail="Ujian belum dimulai") - if now > exam.end_time: - raise HTTPException(status_code=400, detail="Ujian sudah berakhir") + if not exam.is_published: + raise HTTPException(status_code=400, detail="Ujian belum dipublikasikan") - # Enforce the same participant access policy used by token join. - exam_creator_role = await _get_exam_creator_role(db, exam.creator_id) - _ensure_exam_participant_access( - exam, - current_user, - exam_creator_role=exam_creator_role, - ) - - # Check max attempts with COUNT query (avoid loading full session history). - completed_attempts_result = await db.execute( - select(func.count(ExamSession.id)).where( - ExamSession.user_id == current_user.id, - ExamSession.exam_id == exam_id, - ExamSession.status.in_(("completed", "submitted")), - ) - ) - completed_attempts = int(completed_attempts_result.scalar() or 0) - if completed_attempts >= exam.max_attempts: - raise HTTPException(status_code=400, detail="Batas percobaan sudah tercapai") - - # Query only sessions relevant for resume/recovery decisions. - existing_result = await db.execute( - select(ExamSession) - .where( - ExamSession.user_id == current_user.id, - ExamSession.exam_id == exam_id, - ExamSession.status.in_(("in_progress", "active", "terminated", "kicked")), - ) - .order_by(ExamSession.start_time.desc(), ExamSession.id.desc()) - .limit(16) - ) - existing_sessions = existing_result.scalars().all() - - # Preload answer counts only for candidate resume sessions. - answer_counts: Dict[int, int] = {} - if len(existing_sessions) > 1: - existing_session_ids = [s.id for s in existing_sessions] - answer_count_result = await db.execute( - select(Answer.session_id, func.count(Answer.id)) - .where(Answer.session_id.in_(existing_session_ids)) - .group_by(Answer.session_id) - ) - answer_counts = {int(sid): int(cnt or 0) for sid, cnt in answer_count_result.all()} - - # Check for resumable session. - # If duplicate active sessions exist due reconnect/race, prefer the one with most saved answers. - resumable_sessions = [s for s in existing_sessions if s.status in ("in_progress", "active")] - is_resumed_session = False - session = None - if resumable_sessions: - is_resumed_session = True - resumable_sessions.sort( - key=lambda s: ( - answer_counts.get(s.id, 0), - s.start_time or datetime.min.replace(tzinfo=timezone.utc), - s.id - ), - reverse=True - ) - session = resumable_sessions[0] - logger.info( - "EXAM_START | RESUME_SESSION | user=%s exam=%s session=%s answers=%s status=%s", - current_user.id, - exam_id, - session.id, - answer_counts.get(session.id, 0), - session.status - ) - else: - # Auto-reset terminated sessions only when cause is network/disconnection. - recoverable_sessions = [ - s for s in existing_sessions if s.status in ("terminated", "kicked") - ] - recoverable_sessions.sort( - key=lambda s: ( - answer_counts.get(s.id, 0), - s.start_time or datetime.min.replace(tzinfo=timezone.utc), - s.id, - ), - reverse=True, - ) + now = datetime.now(timezone.utc) + if now < exam.start_time: + raise HTTPException(status_code=400, detail="Ujian belum dimulai") + if now > exam.end_time: + raise HTTPException(status_code=400, detail="Ujian sudah berakhir") - candidate_recoveries = [] - for candidate in recoverable_sessions: - logs_result = await db.execute( - select(ExamLog) - .where(ExamLog.session_id == candidate.id) - .order_by(ExamLog.created_at.desc(), ExamLog.id.desc()) - .limit(30) + exam_creator_role = exam.creator.role if exam.creator else None + _ensure_exam_participant_access( + exam, + current_user, + exam_creator_role=exam_creator_role, ) - recovery = evaluate_session_recovery(candidate, logs_result.scalars().all()) - if recovery.get("category") == RECOVERY_CATEGORY_ADMIN: - raise HTTPException( - status_code=409, - detail=( - "Sesi dihentikan oleh pengawas/admin. " - "Hubungi pengawas untuk membuka kembali sesi." - ), - ) - candidate_recoveries.append((candidate, recovery)) - - for candidate, recovery in candidate_recoveries: - if not recovery.get("allow_continue"): - continue - candidate.status = "in_progress" - candidate.end_time = None - candidate.terminated_by_admin = False - candidate.emergency_exit_allowed = False - db.add( - ExamLog( - session_id=candidate.id, - event_type="SESSION_AUTO_RESET_NETWORK", - event_data={ - "category": recovery.get("category"), - "message": recovery.get("message"), - "trigger": "start_exam_session", - }, - ) - ) - await db.commit() - session = candidate - is_resumed_session = True - logger.warning( - "EXAM_START | AUTO_RESET_SESSION | user=%s exam=%s session=%s category=%s", + session_state = await exam_service.get_exam_start_session_state( current_user.id, exam_id, - candidate.id, - recovery.get("category"), ) - break - - if session is None: - # Create new session - client_info = get_client_info(request) - session = ExamSession( - user_id=current_user.id, - exam_id=exam_id, - start_time=now, - status="in_progress", - ip_address=client_info["ip_address"], - user_agent=client_info["user_agent"], - seb_detected=client_info["seb_detected"] - ) - db.add(session) - try: - await db.flush() - db.add( - ExamLog( - session_id=session.id, - event_type="SESSION_START", - event_data={ - "ip": client_info["ip_address"], - "seb_detected": client_info["seb_detected"], - "exam_snapshot": { - "title": exam.title, - "subject": exam.subject, - "exam_type": exam.exam_type, - "allowed_classes": exam.allowed_classes, - "allowed_students": exam.allowed_students, - "start_time": exam.start_time.isoformat() if exam.start_time else None, - "end_time": exam.end_time.isoformat() if exam.end_time else None, - "duration_minutes": exam.duration_minutes, - }, - } + completed_attempts = session_state.attempt_count + if completed_attempts >= exam.max_attempts: + raise HTTPException(status_code=400, detail="Batas percobaan sudah tercapai") + + existing_sessions = session_state.existing_sessions + + # Preload answer counts only for candidate resume sessions. + answer_counts: Dict[int, int] = {} + if len(existing_sessions) > 1: + existing_session_ids = [s.id for s in existing_sessions] + answer_count_result = await db.execute( + select(Answer.session_id, func.count(Answer.id)) + .where(Answer.session_id.in_(existing_session_ids)) + .group_by(Answer.session_id) ) - ) - await db.commit() - except sqlalchemy.exc.IntegrityError as integrity_error: - await db.rollback() - - race_result = await db.execute( - select(ExamSession) - .where( - ExamSession.user_id == current_user.id, - ExamSession.exam_id == exam_id, - ExamSession.status.in_(("in_progress", "active")), + answer_counts = {int(sid): int(cnt or 0) for sid, cnt in answer_count_result.all()} + + # Check for resumable session. + # If duplicate active sessions exist due reconnect/race, prefer the one with most saved answers. + resumable_sessions = [s for s in existing_sessions if s.status in ("in_progress", "active")] + is_resumed_session = False + session = None + if resumable_sessions: + is_resumed_session = True + resumable_sessions.sort( + key=lambda s: ( + answer_counts.get(s.id, 0), + s.start_time or datetime.min.replace(tzinfo=timezone.utc), + s.id + ), + reverse=True ) - .order_by(ExamSession.start_time.desc(), ExamSession.id.desc()) - ) - raced_session = race_result.scalar_one_or_none() - if raced_session is None: - logger.error( - "EXAM_START | ACTIVE_SESSION_RACE_MISS | user=%s exam=%s error=%s", + session = resumable_sessions[0] + logger.info( + "EXAM_START | RESUME_SESSION | user=%s exam=%s session=%s answers=%s status=%s", current_user.id, exam_id, - str(integrity_error), + session.id, + answer_counts.get(session.id, 0), + session.status ) - raise HTTPException( - status_code=409, - detail="Konflik saat memulai sesi ujian, silakan coba lagi.", + else: + # Auto-reset terminated sessions only when cause is network/disconnection. + recoverable_sessions = [ + s for s in existing_sessions if s.status in ("terminated", "kicked") + ] + recoverable_sessions.sort( + key=lambda s: ( + answer_counts.get(s.id, 0), + s.start_time or datetime.min.replace(tzinfo=timezone.utc), + s.id, + ), + reverse=True, ) - is_resumed_session = True - session = raced_session - logger.warning( - "EXAM_START | ACTIVE_SESSION_RACE_RESUME | user=%s exam=%s session=%s", - current_user.id, - exam_id, - session.id, - ) + candidate_recoveries = [] + for candidate in recoverable_sessions: + logs_result = await db.execute( + select(ExamLog) + .options(noload("*")) + .where(ExamLog.session_id == candidate.id) + .order_by(ExamLog.created_at.desc(), ExamLog.id.desc()) + .limit(30) + ) + recovery = evaluate_session_recovery(candidate, logs_result.scalars().all()) + if recovery.get("category") == RECOVERY_CATEGORY_ADMIN: + raise HTTPException( + status_code=409, + detail=( + "Sesi dihentikan oleh pengawas/admin. " + "Hubungi pengawas untuk membuka kembali sesi." + ), + ) + candidate_recoveries.append((candidate, recovery)) + + for candidate, recovery in candidate_recoveries: + if not recovery.get("allow_continue"): + continue + + await db.execute( + update(ExamSession) + .where(ExamSession.id == candidate.id) + .values( + status="in_progress", + end_time=None, + terminated_by_admin=False, + emergency_exit_allowed=False, + ) + ) + candidate.status = "in_progress" + candidate.end_time = None + candidate.terminated_by_admin = False + candidate.emergency_exit_allowed = False + db.add( + ExamLog( + session_id=candidate.id, + event_type="SESSION_AUTO_RESET_NETWORK", + event_data={ + "category": recovery.get("category"), + "message": recovery.get("message"), + "trigger": "start_exam_session", + }, + ) + ) + session = candidate + is_resumed_session = True + logger.warning( + "EXAM_START | AUTO_RESET_SESSION | user=%s exam=%s session=%s category=%s", + current_user.id, + exam_id, + candidate.id, + recovery.get("category"), + ) + break + + if session is None: + # Create new session + client_info = get_client_info(request) + session = ExamSession( + user_id=current_user.id, + exam_id=exam_id, + start_time=now, + status="in_progress", + ip_address=client_info["ip_address"], + user_agent=client_info["user_agent"], + seb_detected=client_info["seb_detected"] + ) + db.add(session) + try: + await db.flush() + db.add( + ExamLog( + session_id=session.id, + event_type="SESSION_START", + event_data={ + "ip": client_info["ip_address"], + "seb_detected": client_info["seb_detected"], + "exam_snapshot": { + "title": exam.title, + "subject": exam.subject, + "exam_type": exam.exam_type, + "allowed_classes": exam.allowed_classes, + "allowed_students": exam.allowed_students, + "start_time": exam.start_time.isoformat() if exam.start_time else None, + "end_time": exam.end_time.isoformat() if exam.end_time else None, + "duration_minutes": exam.duration_minutes, + }, + } + ) + ) + except sqlalchemy.exc.IntegrityError as integrity_error: + await db.rollback() + + race_result = await db.execute( + select(ExamSession) + .options(noload("*")) + .where( + ExamSession.user_id == current_user.id, + ExamSession.exam_id == exam_id, + ExamSession.status.in_(("in_progress", "active")), + ) + .order_by(ExamSession.start_time.desc(), ExamSession.id.desc()) + ) + raced_session = race_result.scalar_one_or_none() + if raced_session is None: + logger.error( + "EXAM_START | ACTIVE_SESSION_RACE_MISS | user=%s exam=%s error=%s", + current_user.id, + exam_id, + str(integrity_error), + ) + raise HTTPException( + status_code=409, + detail="Konflik saat memulai sesi ujian, silakan coba lagi.", + ) - # Release any open read transaction before Redis calls. - await db.commit() + is_resumed_session = True + session = raced_session + logger.warning( + "EXAM_START | ACTIVE_SESSION_RACE_RESUME | user=%s exam=%s session=%s", + current_user.id, + exam_id, + session.id, + ) - # Store session in Redis with idempotent timer data. - # Do NOT overwrite started_at for an existing/resumed session. - existing_redis_data = await get_session_data(session.id) if is_resumed_session else None - started_at_iso = ( - (existing_redis_data or {}).get("started_at") - or session.start_time.isoformat() - ) - session_cache_data = { - "session_id": session.id, - "user_id": current_user.id, - "exam_id": exam_id, - "start_time": session.start_time.isoformat(), - "end_time": session.end_time.isoformat() if session.end_time else None, - "started_at": started_at_iso, - "duration_seconds": exam.duration_minutes * 60, - "elapsed_seconds": int((existing_redis_data or {}).get("elapsed_seconds") or 0), - "paused": False, - "duration_minutes": exam.duration_minutes, - "status": "in_progress", - "answered_count": int((existing_redis_data or {}).get("answered_count") or 0), - "answered_count_stale": False, - "total_questions": int((existing_redis_data or {}).get("total_questions") or 0), - "violation_count": int(session.violation_count or 0), - } - total_paused_seconds = max( - int((existing_redis_data or {}).get("total_paused_seconds") or 0), - int(session.total_paused_seconds or 0), - ) - if total_paused_seconds > 0: - session_cache_data["total_paused_seconds"] = total_paused_seconds - await store_session_data(session.id, session_cache_data) - - # Broadcast session start - await _publish_exam_monitor_event(exam_id, { - "type": "student_started", - "user_id": current_user.id, - "username": current_user.username, - "session_id": session.id, - "timestamp": now.isoformat() - }) - - questions_payload = await exam_service.get_questions_payload(exam_id) - if not questions_payload: - raise HTTPException(status_code=404, detail="Soal ujian tidak ditemukan") - - total_questions_from_payload = len(questions_payload) - if int(session_cache_data.get("total_questions") or 0) != total_questions_from_payload: - session_cache_data["total_questions"] = total_questions_from_payload + await db.commit() + + # Store session in Redis with idempotent timer data. + # Do NOT overwrite started_at for an existing/resumed session. + existing_redis_data = await get_session_data(session.id) if is_resumed_session else None + started_at_iso = ( + (existing_redis_data or {}).get("started_at") + or session.start_time.isoformat() + ) + session_cache_data = { + "session_id": session.id, + "user_id": current_user.id, + "exam_id": exam_id, + "start_time": session.start_time.isoformat(), + "end_time": session.end_time.isoformat() if session.end_time else None, + "started_at": started_at_iso, + "duration_seconds": exam.duration_minutes * 60, + "elapsed_seconds": int((existing_redis_data or {}).get("elapsed_seconds") or 0), + "paused": False, + "duration_minutes": exam.duration_minutes, + "status": "in_progress", + "answered_count": int((existing_redis_data or {}).get("answered_count") or 0), + "answered_count_stale": False, + "total_questions": int((existing_redis_data or {}).get("total_questions") or 0), + "violation_count": int(session.violation_count or 0), + } + total_paused_seconds = max( + int((existing_redis_data or {}).get("total_paused_seconds") or 0), + int(session.total_paused_seconds or 0), + ) + if total_paused_seconds > 0: + session_cache_data["total_paused_seconds"] = total_paused_seconds await store_session_data(session.id, session_cache_data) - questions = _build_start_question_responses( - questions_payload, - exam_id=exam.id, - user_id=current_user.id, - shuffle_questions=bool(exam.shuffle_questions), - shuffle_options=bool(exam.shuffle_options), - secret_key=settings.secret_key, - ) + # Broadcast session start + await _publish_exam_monitor_event(exam_id, { + "type": "student_started", + "user_id": current_user.id, + "username": current_user.username, + "session_id": session.id, + "timestamp": now.isoformat() + }) - return ExamStartResponse( - session_id=session.id, - exam_id=exam.id, - exam_title=exam.title, - duration_minutes=exam.duration_minutes, - question_count=len(questions), - start_time=session.start_time, - end_time=session.start_time + timedelta(minutes=exam.duration_minutes), - server_time=datetime.now(timezone.utc), - show_results=exam.show_results, - show_teacher_name=exam.show_teacher_name if exam.show_teacher_name is not None else True, - teacher_name=exam.creator.full_name if (exam.show_teacher_name and exam.creator) else None, - subject=exam.subject, - exam_type=exam.exam_type, - shuffle_questions=bool(exam.shuffle_questions), - shuffle_options=bool(exam.shuffle_options), - session_poll_token=create_session_poll_token( - session_id=session.id, + questions_payload = await exam_service.get_questions_payload(exam_id) + if not questions_payload: + raise HTTPException(status_code=404, detail="Soal ujian tidak ditemukan") + + total_questions_from_payload = len(questions_payload) + if int(session_cache_data.get("total_questions") or 0) != total_questions_from_payload: + session_cache_data["total_questions"] = total_questions_from_payload + await store_session_data(session.id, session_cache_data) + + questions = _build_start_question_responses( + questions_payload, + exam_id=exam.id, user_id=current_user.id, - expires_minutes=SESSION_POLL_TOKEN_EXPIRES_MINUTES, - ), - session_poll_token_expires_minutes=SESSION_POLL_TOKEN_EXPIRES_MINUTES, - questions=questions - ) + shuffle_questions=bool(exam.shuffle_questions), + shuffle_options=bool(exam.shuffle_options), + secret_key=settings.secret_key, + ) + + return ExamStartResponse( + session_id=session.id, + exam_id=exam.id, + exam_title=exam.title, + duration_minutes=exam.duration_minutes, + question_count=len(questions), + start_time=session.start_time, + end_time=session.start_time + timedelta(minutes=exam.duration_minutes), + server_time=datetime.now(timezone.utc), + show_results=exam.show_results, + show_teacher_name=exam.show_teacher_name if exam.show_teacher_name is not None else True, + teacher_name=exam.creator.full_name if (exam.show_teacher_name and exam.creator) else None, + subject=exam.subject, + exam_type=exam.exam_type, + shuffle_questions=bool(exam.shuffle_questions), + shuffle_options=bool(exam.shuffle_options), + session_poll_token=create_session_poll_token( + session_id=session.id, + user_id=current_user.id, + expires_minutes=SESSION_POLL_TOKEN_EXPIRES_MINUTES, + ), + session_poll_token_expires_minutes=SESSION_POLL_TOKEN_EXPIRES_MINUTES, + questions=questions + ) # ============== SPRINT 1.3: NEW ENDPOINTS ============== diff --git a/app/core/cache.py b/app/core/cache.py index 1720bac..b2c070b 100644 --- a/app/core/cache.py +++ b/app/core/cache.py @@ -9,9 +9,12 @@ from app.core.redis_pubsub import get_redis from app.database import async_session_read from app.core.apk_profiles import parse_signature_profiles, get_allowed_tokens +from app.core.singleflight import KeyedSingleFlight +from app.core.start_db_admission import start_db_segment from app.models.system_settings import SystemSettings logger = logging.getLogger(__name__) +_security_cache_fills = KeyedSingleFlight[str]() async def _cache_get_json(cache_key: str) -> Optional[Any]: @@ -46,16 +49,23 @@ async def is_developer_mode_enabled() -> bool: if cached is not None: return bool(cached) - try: - async with async_session_read() as db: - result = await db.execute(select(SystemSettings)) - setting = result.scalar_one_or_none() - enabled = bool(setting.allow_browser_testing) if setting else False - await _cache_set_json(cache_key, enabled, ttl_seconds=60) - return enabled - except Exception as exc: - logger.error(f"Failed to check developer mode: {exc}") - return False # Fail secure - default to enforcing SEB + async def fill() -> bool: + refreshed = await _cache_get_json(cache_key) + if refreshed is not None: + return bool(refreshed) + try: + async with start_db_segment("security"): + async with async_session_read() as db: + result = await db.execute(select(SystemSettings)) + setting = result.scalar_one_or_none() + enabled = bool(setting.allow_browser_testing) if setting else False + await _cache_set_json(cache_key, enabled, ttl_seconds=60) + return enabled + except Exception as exc: + logger.error(f"Failed to check developer mode: {exc}") + return False # Fail secure - default to enforcing SEB + + return await _security_cache_fills.run(cache_key, fill) async def clear_developer_mode_cache() -> None: @@ -83,16 +93,23 @@ async def is_freeze_mode_enabled() -> bool: if cached is not None: return bool(cached) - try: - async with async_session_read() as db: - result = await db.execute(select(SystemSettings)) - setting = result.scalar_one_or_none() - enabled = bool(getattr(setting, "freeze_mode", False)) if setting else False - await _cache_set_json(cache_key, enabled, ttl_seconds=15) - return enabled - except Exception as exc: - logger.error(f"Failed to check freeze mode: {exc}") - return False + async def fill() -> bool: + refreshed = await _cache_get_json(cache_key) + if refreshed is not None: + return bool(refreshed) + try: + async with start_db_segment("security"): + async with async_session_read() as db: + result = await db.execute(select(SystemSettings)) + setting = result.scalar_one_or_none() + enabled = bool(getattr(setting, "freeze_mode", False)) if setting else False + await _cache_set_json(cache_key, enabled, ttl_seconds=15) + return enabled + except Exception as exc: + logger.error(f"Failed to check freeze mode: {exc}") + return False + + return await _security_cache_fills.run(cache_key, fill) async def get_allowed_signatures() -> list[str]: @@ -102,20 +119,27 @@ async def get_allowed_signatures() -> list[str]: if cached is not None: return cached if isinstance(cached, list) else [] - try: - async with async_session_read() as db: - result = await db.execute(select(SystemSettings)) - setting = result.scalar_one_or_none() - signatures: list[str] = [] - if setting and setting.allowed_signatures: - signatures = parse_signature_profiles(setting.allowed_signatures).get( - "all_signatures", [] - ) - await _cache_set_json(cache_key, signatures, ttl_seconds=60) - return signatures - except Exception as exc: - logger.error(f"Failed to get signatures: {exc}") - return [] + async def fill() -> list[str]: + refreshed = await _cache_get_json(cache_key) + if refreshed is not None: + return refreshed if isinstance(refreshed, list) else [] + try: + async with start_db_segment("security"): + async with async_session_read() as db: + result = await db.execute(select(SystemSettings)) + setting = result.scalar_one_or_none() + signatures: list[str] = [] + if setting and setting.allowed_signatures: + signatures = parse_signature_profiles(setting.allowed_signatures).get( + "all_signatures", [] + ) + await _cache_set_json(cache_key, signatures, ttl_seconds=60) + return signatures + except Exception as exc: + logger.error(f"Failed to get signatures: {exc}") + return [] + + return list(await _security_cache_fills.run(cache_key, fill)) async def get_allowed_apk_tokens() -> list[str]: diff --git a/app/core/security.py b/app/core/security.py index 9c2e243..70df0f9 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -14,6 +14,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +from sqlalchemy.orm import noload from app.config import settings from app.database import get_db_read @@ -226,7 +227,9 @@ async def _resolve_authenticated_user(token: str, db: AsyncSession) -> Optional[ ): return cached_user - result = await db.execute(select(User).where(User.id == token_data.user_id)) + result = await db.execute( + select(User).options(noload("*")).where(User.id == token_data.user_id) + ) user = result.scalar_one_or_none() if user is None: return None @@ -455,7 +458,9 @@ async def get_current_user_for_refresh( if cached_user is not None and cached_user.id == token_data.user_id: user = cached_user else: - result = await db.execute(select(User).where(User.id == token_data.user_id)) + result = await db.execute( + select(User).options(noload("*")).where(User.id == token_data.user_id) + ) db_user = result.scalar_one_or_none() user = _build_authenticated_user(db_user) if db_user is not None else None if user is not None: diff --git a/app/core/singleflight.py b/app/core/singleflight.py new file mode 100644 index 0000000..e131960 --- /dev/null +++ b/app/core/singleflight.py @@ -0,0 +1,56 @@ +import asyncio +import os +from collections.abc import Awaitable, Callable, Hashable +from typing import Any, Generic, TypeVar + + +KeyT = TypeVar("KeyT", bound=Hashable) +ValueT = TypeVar("ValueT") + + +class KeyedSingleFlight(Generic[KeyT]): + """Deduplicate concurrent coroutine calls for one key in one worker.""" + + def __init__(self) -> None: + self._pid: int | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._inflight: dict[KeyT, asyncio.Future[Any]] = {} + + def _ensure_worker_context(self) -> asyncio.AbstractEventLoop: + loop = asyncio.get_running_loop() + pid = os.getpid() + if self._pid != pid or self._loop is not loop: + self._pid = pid + self._loop = loop + self._inflight = {} + return loop + + async def run( + self, + key: KeyT, + loader: Callable[[], Awaitable[ValueT]], + ) -> ValueT: + loop = self._ensure_worker_context() + existing = self._inflight.get(key) + if existing is not None: + return await asyncio.shield(existing) + + future: asyncio.Future[ValueT] = loop.create_future() + self._inflight[key] = future + try: + result = await loader() + except asyncio.CancelledError: + future.cancel() + raise + except BaseException as exc: + future.set_exception(exc) + # The loader raises directly; retrieving here avoids an unhandled + # Future warning when there were no concurrent waiters. + future.exception() + raise + else: + future.set_result(result) + return result + finally: + if self._inflight.get(key) is future: + self._inflight.pop(key, None) diff --git a/app/core/start_db_admission.py b/app/core/start_db_admission.py new file mode 100644 index 0000000..b456a8d --- /dev/null +++ b/app/core/start_db_admission.py @@ -0,0 +1,270 @@ +from __future__ import annotations + +import asyncio +import os +import re +import time +from contextlib import asynccontextmanager +from contextvars import ContextVar, Token +from typing import Any, AsyncIterator, Optional + + +START_PATH_RE = re.compile(r"^/api/exams/\d+/start$") +_DEFAULT_LIMIT = 0 + +_pid: Optional[int] = None +_loop: Optional[asyncio.AbstractEventLoop] = None +_gate: Optional["ProcessAdmissionGate"] = None +_forced_limit: Optional[int] = None +_current_lease: ContextVar[Optional["StartAdmissionLease"]] = ContextVar( + "siab1_start_db_admission_lease", + default=None, +) + + +def is_exam_start_path(path: str) -> bool: + return bool(START_PATH_RE.match(path or "")) + + +def _parse_admission_limit(raw: Optional[str]) -> int: + if raw is None: + return _DEFAULT_LIMIT + text = raw.strip() + if not text: + return _DEFAULT_LIMIT + try: + value = int(text) + except ValueError: + return _DEFAULT_LIMIT + if value < 0: + return _DEFAULT_LIMIT + return value + + +def _admission_limit() -> int: + if _forced_limit is not None: + return _parse_admission_limit(str(_forced_limit)) + return _parse_admission_limit(os.getenv("START_DB_ADMISSION_LIMIT")) + + +class ProcessAdmissionGate: + def __init__(self, limit: int) -> None: + self.limit = limit + self.semaphore = asyncio.Semaphore(limit) if limit > 0 else None + self.holders = 0 + self.waiters = 0 + self.peak_holders = 0 + self.peak_waiters = 0 + + +def _get_gate(limit: int) -> ProcessAdmissionGate: + global _pid, _loop, _gate + loop = asyncio.get_running_loop() + pid = os.getpid() + if _gate is None or _pid != pid or _loop is not loop or _gate.limit != limit: + _pid = pid + _loop = loop + _gate = ProcessAdmissionGate(limit) + return _gate + + +def configure_start_admission(*, limit: Optional[int] = None) -> None: + global _forced_limit, _pid, _loop, _gate + _forced_limit = limit + _pid = None + _loop = None + _gate = None + + +def reset_start_admission_for_tests() -> None: + configure_start_admission(limit=None) + _current_lease.set(None) + + +def current_start_admission() -> Optional["StartAdmissionLease"]: + return _current_lease.get() + + +def process_admission_snapshot() -> dict[str, Any]: + limit = _admission_limit() + gate = _gate + if gate is None: + return { + "pid": os.getpid(), + "limit": limit, + "holders": 0, + "waiters": 0, + "peak_holders": 0, + "peak_waiters": 0, + } + return { + "pid": os.getpid(), + "limit": gate.limit, + "holders": gate.holders, + "waiters": gate.waiters, + "peak_holders": gate.peak_holders, + "peak_waiters": gate.peak_waiters, + } + + +class StartAdmissionLease: + def __init__(self, request: Any, gate: ProcessAdmissionGate) -> None: + self.request = request + self.gate = gate + self.depth = 0 + self.bind_depth = 1 + self.acquisitions: list[dict[str, Any]] = [] + + def publish(self) -> None: + wait_ms = sum( + float(item.get("wait_ms") or 0.0) + for item in self.acquisitions + if not item.get("nested") + ) + snapshot = { + "pid": os.getpid(), + "limit": self.gate.limit, + "holders": self.gate.holders, + "waiters": self.gate.waiters, + "peak_holders": self.gate.peak_holders, + "peak_waiters": self.gate.peak_waiters, + "wait_ms": wait_ms, + "acquisitions": list(self.acquisitions), + } + request = self.request + if request is not None: + state = getattr(request, "state", None) + if state is not None: + state.start_db_admission = snapshot + + @asynccontextmanager + async def acquire(self, segment: str) -> AsyncIterator[dict[str, Any]]: + if self.depth > 0: + started = time.monotonic() + started_wall = time.time() + record = { + "segment": segment, + "nested": True, + "wait_ms": 0.0, + "hold_ms": 0.0, + "acquired_wall": started_wall, + "released_wall": None, + "holders_at_acquire": self.gate.holders, + "waiters_at_acquire": self.gate.waiters, + } + self.acquisitions.append(record) + self.depth += 1 + self.publish() + try: + yield record + finally: + record["hold_ms"] = (time.monotonic() - started) * 1000.0 + record["released_wall"] = time.time() + self.depth -= 1 + self.publish() + return + + if self.gate.limit <= 0 or self.gate.semaphore is None: + started = time.monotonic() + started_wall = time.time() + record = { + "segment": segment, + "nested": False, + "wait_ms": 0.0, + "hold_ms": 0.0, + "acquired_wall": started_wall, + "released_wall": None, + "holders_at_acquire": 0, + "waiters_at_acquire": 0, + } + self.acquisitions.append(record) + self.depth = 1 + self.publish() + try: + yield record + finally: + record["hold_ms"] = (time.monotonic() - started) * 1000.0 + record["released_wall"] = time.time() + self.depth = 0 + self.publish() + return + + gate = self.gate + gate.waiters += 1 + gate.peak_waiters = max(gate.peak_waiters, gate.waiters) + wait_started = time.monotonic() + try: + await gate.semaphore.acquire() + except BaseException: + gate.waiters = max(0, gate.waiters - 1) + self.publish() + raise + acquired = time.monotonic() + gate.waiters = max(0, gate.waiters - 1) + gate.holders += 1 + gate.peak_holders = max(gate.peak_holders, gate.holders) + record = { + "segment": segment, + "nested": False, + "wait_ms": (acquired - wait_started) * 1000.0, + "hold_ms": 0.0, + "acquired_wall": time.time(), + "released_wall": None, + "holders_at_acquire": gate.holders, + "waiters_at_acquire": gate.waiters, + "peak_holders": gate.peak_holders, + "peak_waiters": gate.peak_waiters, + } + self.acquisitions.append(record) + self.depth = 1 + self.publish() + try: + yield record + finally: + record["hold_ms"] = (time.monotonic() - acquired) * 1000.0 + record["released_wall"] = time.time() + record["peak_holders"] = gate.peak_holders + record["peak_waiters"] = gate.peak_waiters + self.depth = 0 + gate.holders = max(0, gate.holders - 1) + gate.semaphore.release() + self.publish() + + +@asynccontextmanager +async def bind_start_admission(request: Any = None) -> AsyncIterator[StartAdmissionLease]: + existing = _current_lease.get() + if existing is not None: + existing.bind_depth += 1 + if request is not None and existing.request is None: + existing.request = request + try: + yield existing + finally: + existing.bind_depth -= 1 + return + + gate = _get_gate(_admission_limit()) + lease = StartAdmissionLease(request, gate) + token: Token = _current_lease.set(lease) + lease.publish() + try: + yield lease + finally: + _current_lease.reset(token) + + +@asynccontextmanager +async def start_db_segment(segment: str) -> AsyncIterator[dict[str, Any]]: + lease = _current_lease.get() + if lease is None: + yield { + "segment": segment, + "nested": False, + "wait_ms": 0.0, + "hold_ms": 0.0, + "skipped": True, + } + return + async with lease.acquire(segment) as record: + yield record diff --git a/app/main.py b/app/main.py index c1a8367..926424d 100644 --- a/app/main.py +++ b/app/main.py @@ -301,6 +301,10 @@ async def lifespan(app: FastAPI): from app.middleware.performance_monitoring import PerformanceMonitoringMiddleware app.add_middleware(PerformanceMonitoringMiddleware) +# Bind START admission context before freeze/SXB/security cache fills. +from app.middleware.start_admission_bind import StartAdmissionBindMiddleware +app.add_middleware(StartAdmissionBindMiddleware) + # Mount static files app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/app/middleware/seb_validation.py b/app/middleware/seb_validation.py index 2cfd2fe..8711d01 100644 --- a/app/middleware/seb_validation.py +++ b/app/middleware/seb_validation.py @@ -16,6 +16,7 @@ from sqlalchemy import select from app.core.request_security_memo import allowed_signatures, developer_mode_enabled +from app.core.start_db_admission import start_db_segment from app.core.seb import ( validate_seb_config_key_hash, validate_seb_request_hash, @@ -51,9 +52,10 @@ async def _get_allow_mobile_apps_cached() -> bool: try: from app.database import async_session_read - async with async_session_read() as session: - result = await session.execute(select(SystemSettings.allow_mobile_apps)) - allow_mobile = result.scalar_one_or_none() + async with start_db_segment("security"): + async with async_session_read() as session: + result = await session.execute(select(SystemSettings.allow_mobile_apps)) + allow_mobile = result.scalar_one_or_none() _allow_mobile_cache["allow_mobile"] = True if allow_mobile is None else bool(allow_mobile) except Exception: # Keep the last known value during transient DB pressure. @@ -83,10 +85,16 @@ async def _get_exam_seb_keys_cached( if cached and now < cached[0]: return cached[1], cached[2] - result = await db.execute( - select(Exam.seb_config_key, Exam.seb_browser_exam_key).where(Exam.id == exam_id) - ) - row = result.first() + from app.database import async_session_read + + async with start_db_segment("security"): + async with async_session_read() as session: + result = await session.execute( + select(Exam.seb_config_key, Exam.seb_browser_exam_key).where( + Exam.id == exam_id + ) + ) + row = result.first() if not row: return None diff --git a/app/middleware/security.py b/app/middleware/security.py index a68ea50..49029fa 100644 --- a/app/middleware/security.py +++ b/app/middleware/security.py @@ -4,6 +4,7 @@ """ import hashlib import json +import os import re from collections import defaultdict, deque from typing import Callable @@ -17,6 +18,7 @@ _SIMPLE_BYTE_RANGE_RE = re.compile(r"^bytes=\d{0,20}-\d{0,20}$", re.IGNORECASE) +_REPLICA_NAME = (os.getenv("SIAB_REPLICA") or "").strip() def _request_is_https(request: Request) -> bool: @@ -111,6 +113,8 @@ async def dispatch(self, request: Request, call_next: Callable) -> Response: response.headers["X-Permitted-Cross-Domain-Policies"] = "none" response.headers["Cross-Origin-Opener-Policy"] = "same-origin-allow-popups" # Allow YouTube popups response.headers["Cross-Origin-Resource-Policy"] = "same-site" # Allow same-site resources + if _REPLICA_NAME: + response.headers["X-SIAB-Replica"] = _REPLICA_NAME # Content Security Policy (IMPROVED - Issue #2 fix) # Removed 'unsafe-eval' for better security diff --git a/app/middleware/start_admission_bind.py b/app/middleware/start_admission_bind.py new file mode 100644 index 0000000..285e177 --- /dev/null +++ b/app/middleware/start_admission_bind.py @@ -0,0 +1,15 @@ +from typing import Callable + +from fastapi import Request +from fastapi.responses import Response +from starlette.middleware.base import BaseHTTPMiddleware + +from app.core.start_db_admission import bind_start_admission, is_exam_start_path + + +class StartAdmissionBindMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next: Callable) -> Response: + if request.method == "POST" and is_exam_start_path(request.url.path): + async with bind_start_admission(request): + return await call_next(request) + return await call_next(request) diff --git a/app/services/answer_runtime_buffer.py b/app/services/answer_runtime_buffer.py index a543090..fbfe2c7 100644 --- a/app/services/answer_runtime_buffer.py +++ b/app/services/answer_runtime_buffer.py @@ -16,6 +16,7 @@ from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import noload from app.config import settings from app.core.exam_runtime_cache import invalidate_session_answer_count_cache @@ -145,7 +146,9 @@ async def _ensure_active_session_for_user( user_id: int, ) -> ExamSession: result = await db.execute( - select(ExamSession).where( + select(ExamSession) + .options(noload("*")) + .where( ExamSession.id == session_id, ExamSession.user_id == user_id, ExamSession.status.in_(["in_progress", "active"]), @@ -423,7 +426,9 @@ async def _flush_session_buffer(db: AsyncSession, redis: Any, session_id: int) - return 0 session_result = await db.execute( - select(ExamSession).where( + select(ExamSession) + .options(noload("*")) + .where( ExamSession.id == session_id, ExamSession.status.in_(["in_progress", "active"]), ) @@ -435,7 +440,9 @@ async def _flush_session_buffer(db: AsyncSession, redis: Any, session_id: int) - await _acquire_session_write_lock(db, session_id) existing_result = await db.execute( - select(Answer).where( + select(Answer) + .options(noload("*")) + .where( Answer.session_id == session_id, Answer.question_id.in_([int(item["question_id"]) for item in payloads]), ) diff --git a/app/services/answer_sync_service.py b/app/services/answer_sync_service.py index d05d5f6..e42eec1 100644 --- a/app/services/answer_sync_service.py +++ b/app/services/answer_sync_service.py @@ -17,6 +17,7 @@ from sqlalchemy import func, or_, select, text, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import noload from app.config import settings from app.core.exam_answer_validation import ( @@ -149,7 +150,7 @@ async def _ensure_session_in_progress_for_user( user_id: int, lock_row: bool = False, ) -> ExamSession: - query = select(ExamSession).where( + query = select(ExamSession).options(noload("*")).where( ExamSession.id == session_id, ExamSession.user_id == user_id, ) @@ -439,6 +440,7 @@ async def _lock_session_for_single_answer( await _acquire_session_write_lock(self.db, session_id) result = await self.db.execute( select(ExamSession) + .options(noload("*")) .where( ExamSession.id == session_id, ExamSession.user_id == self.current_user.id, @@ -586,7 +588,9 @@ async def _publish_progress_if_needed( async def accept_legacy_autosave(self, save_data: AutoSaveRequest) -> AutoSaveResponse: """Handle legacy autosave cache updates without direct answer writes.""" result = await self.db.execute( - select(ExamSession).where( + select(ExamSession) + .options(noload("*")) + .where( ExamSession.id == save_data.session_id, ExamSession.user_id == self.current_user.id, ExamSession.status == "in_progress", @@ -626,7 +630,9 @@ async def accept_legacy_autosave(self, save_data: AutoSaveRequest) -> AutoSaveRe async def accept_batch(self, batch_data: Any) -> Dict[str, Any]: """Persist batch autosave in direct DB mode with no-op update skip.""" result = await self.db.execute( - select(ExamSession).where( + select(ExamSession) + .options(noload("*")) + .where( ExamSession.id == batch_data.session_id, ExamSession.user_id == self.current_user.id, ExamSession.status == "in_progress", @@ -691,7 +697,9 @@ async def accept_batch(self, batch_data: Any) -> Dict[str, Any]: existing_answer_map: Dict[int, Answer] = {} if valid_question_ids: existing_result = await self.db.execute( - select(Answer).where( + select(Answer) + .options(noload("*")) + .where( Answer.session_id == session_id_value, Answer.question_id.in_(valid_question_ids), ) @@ -786,6 +794,7 @@ async def _retry_batch_serialized(self, session_id_value: int, valid_answers: Li incoming_metadata = dict(answer_data.answer_metadata or {}) retry_existing_result = await self.db.execute( select(Answer) + .options(noload("*")) .where( Answer.session_id == session_id_value, Answer.question_id == int(answer_data.question_id), @@ -840,7 +849,9 @@ async def accept_journal_events( ) result = await self.db.execute( - select(ExamSession).where( + select(ExamSession) + .options(noload("*")) + .where( ExamSession.id == sync_data.session_id, ExamSession.user_id == self.current_user.id, ExamSession.status.in_(["in_progress", "active"]), @@ -1081,7 +1092,9 @@ async def _load_existing_answers(self, session_id_value: int, question_ids: List if not question_ids: return {} existing_result = await self.db.execute( - select(Answer).where( + select(Answer) + .options(noload("*")) + .where( Answer.session_id == session_id_value, Answer.question_id.in_(question_ids), ) diff --git a/app/services/exam_service.py b/app/services/exam_service.py index 9f7ba3e..ed6aa4a 100644 --- a/app/services/exam_service.py +++ b/app/services/exam_service.py @@ -1,15 +1,125 @@ +from dataclasses import dataclass +from datetime import datetime from typing import List, Optional, Dict, Any import json import logging from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from sqlalchemy.orm import selectinload +from sqlalchemy import func, select, true +from sqlalchemy.orm import joinedload, noload, selectinload +from sqlalchemy.sql import Select from app.models.exam import Exam from app.models.question import Question +from app.models.session import ExamSession +from app.models.user import User from app.core.cache_manager import cache_manager +from app.core.singleflight import KeyedSingleFlight +from app.core.start_db_admission import start_db_segment +from app.database import async_session_read logger = logging.getLogger(__name__) +_question_payload_fills = KeyedSingleFlight[str]() + + +@dataclass(frozen=True) +class ExamStartCreatorView: + full_name: Optional[str] + role: Optional[str] + + +@dataclass(frozen=True) +class ExamStartProjection: + id: int + creator_id: int + is_published: bool + start_time: datetime + end_time: datetime + max_attempts: int + allowed_classes: Optional[str] + allowed_students: Optional[str] + duration_minutes: int + shuffle_questions: bool + shuffle_options: bool + title: str + subject: Optional[str] + exam_type: Optional[str] + show_results: bool + show_teacher_name: Optional[bool] + creator: Optional[ExamStartCreatorView] + + +COMPLETED_ATTEMPT_STATUSES = ("completed", "submitted") +START_EXISTING_SESSION_STATUSES = ("in_progress", "active", "terminated", "kicked") +START_EXISTING_SESSION_LIMIT = 16 + + +@dataclass +class ExamStartSessionRow: + id: int + status: str + start_time: datetime + end_time: Optional[datetime] + terminated_by_admin: bool + emergency_exit_allowed: bool + violation_count: int + total_paused_seconds: int + + +@dataclass +class ExamStartSessionState: + attempt_count: int + existing_sessions: List[ExamStartSessionRow] + + +def build_exam_start_session_state_statement( + user_id: int, + exam_id: int, +) -> Select: + attempt_count_subq = ( + select(func.count(ExamSession.id).label("attempt_count")) + .where( + ExamSession.user_id == user_id, + ExamSession.exam_id == exam_id, + ExamSession.status.in_(COMPLETED_ATTEMPT_STATUSES), + ) + .subquery() + ) + existing_subq = ( + select( + ExamSession.id, + ExamSession.status, + ExamSession.start_time, + ExamSession.end_time, + ExamSession.terminated_by_admin, + ExamSession.emergency_exit_allowed, + ExamSession.violation_count, + ExamSession.total_paused_seconds, + ) + .where( + ExamSession.user_id == user_id, + ExamSession.exam_id == exam_id, + ExamSession.status.in_(START_EXISTING_SESSION_STATUSES), + ) + .order_by(ExamSession.start_time.desc(), ExamSession.id.desc()) + .limit(START_EXISTING_SESSION_LIMIT) + .subquery() + ) + return ( + select( + attempt_count_subq.c.attempt_count, + existing_subq.c.id, + existing_subq.c.status, + existing_subq.c.start_time, + existing_subq.c.end_time, + existing_subq.c.terminated_by_admin, + existing_subq.c.emergency_exit_allowed, + existing_subq.c.violation_count, + existing_subq.c.total_paused_seconds, + ) + .select_from(attempt_count_subq) + .outerjoin(existing_subq, true()) + ) + class ExamService: def __init__(self, db: AsyncSession): @@ -17,20 +127,113 @@ def __init__(self, db: AsyncSession): async def get_exam_metadata(self, exam_id: int) -> Optional[Exam]: """Get exam metadata (without questions)""" - # Try cache for metadata? (Phase 3 optimization) - # For now, DB query - result = await self.db.execute(select(Exam).where(Exam.id == exam_id)) + result = await self.db.execute( + select(Exam).options(noload("*")).where(Exam.id == exam_id) + ) return result.scalar_one_or_none() async def get_exam_with_settings(self, exam_id: int) -> Optional[Exam]: """Get exam metadata + creator info (for start session)""" result = await self.db.execute( select(Exam) - .options(selectinload(Exam.creator)) # Need creator for teacher_name + .options( + noload("*"), + joinedload(Exam.creator).options(noload("*")), + ) .where(Exam.id == exam_id) ) return result.scalar_one_or_none() + async def get_exam_start_projection( + self, + exam_id: int, + ) -> Optional[ExamStartProjection]: + result = await self.db.execute( + select( + Exam.id, + Exam.creator_id, + Exam.is_published, + Exam.start_time, + Exam.end_time, + Exam.max_attempts, + Exam.allowed_classes, + Exam.allowed_students, + Exam.duration_minutes, + Exam.shuffle_questions, + Exam.shuffle_options, + Exam.title, + Exam.subject, + Exam.exam_type, + Exam.show_results, + Exam.show_teacher_name, + User.full_name, + User.role, + ) + .select_from(Exam) + .outerjoin(User, User.id == Exam.creator_id) + .where(Exam.id == exam_id) + ) + row = result.one_or_none() + if row is None: + return None + creator = None + if row.full_name is not None or row.role is not None: + creator = ExamStartCreatorView( + full_name=row.full_name, + role=row.role, + ) + return ExamStartProjection( + id=int(row.id), + creator_id=int(row.creator_id), + is_published=bool(row.is_published), + start_time=row.start_time, + end_time=row.end_time, + max_attempts=int(row.max_attempts or 1), + allowed_classes=row.allowed_classes, + allowed_students=row.allowed_students, + duration_minutes=int(row.duration_minutes or 0), + shuffle_questions=bool(row.shuffle_questions), + shuffle_options=bool(row.shuffle_options), + title=str(row.title), + subject=row.subject, + exam_type=row.exam_type, + show_results=bool(row.show_results), + show_teacher_name=row.show_teacher_name, + creator=creator, + ) + + async def get_exam_start_session_state( + self, + user_id: int, + exam_id: int, + ) -> ExamStartSessionState: + result = await self.db.execute( + build_exam_start_session_state_statement(user_id, exam_id) + ) + rows = result.all() + attempt_count = 0 + existing_sessions: List[ExamStartSessionRow] = [] + for row in rows: + attempt_count = int(row.attempt_count or 0) + if row.id is None: + continue + existing_sessions.append( + ExamStartSessionRow( + id=int(row.id), + status=str(row.status), + start_time=row.start_time, + end_time=row.end_time, + terminated_by_admin=bool(row.terminated_by_admin), + emergency_exit_allowed=bool(row.emergency_exit_allowed), + violation_count=int(row.violation_count or 0), + total_paused_seconds=int(row.total_paused_seconds or 0), + ) + ) + return ExamStartSessionState( + attempt_count=attempt_count, + existing_sessions=existing_sessions, + ) + async def get_questions_payload(self, exam_id: int) -> List[Dict[str, Any]]: """ Get cached exam questions payload (without is_correct). @@ -41,49 +244,50 @@ async def get_questions_payload(self, exam_id: int) -> List[Dict[str, Any]]: if cached_data: return json.loads(cached_data) - # 2. Database Fallback - result = await self.db.execute( - select(Exam) - .options( - selectinload(Exam.questions).selectinload(Question.options) - ) - .where(Exam.id == exam_id) - ) - exam = result.scalar_one_or_none() + async def fill() -> str: + refreshed = await cache_manager.get(cache_key) + if refreshed: + return refreshed + async with start_db_segment("questions"): + async with async_session_read() as db: + result = await db.execute( + select(Question) + .options( + noload("*"), + selectinload(Question.options).options(noload("*")), + ) + .where(Question.exam_id == exam_id) + ) + questions = list(result.scalars().all()) + questions_data = [] + for q in sorted(questions, key=lambda x: x.order_index): + options = [ + { + "id": opt.id, + "option_text": opt.option_text, + "order_index": opt.order_index, + "option_group": opt.option_group or "standard", + "pair_id": opt.pair_id + } + for opt in sorted(q.options, key=lambda x: x.order_index) + ] + questions_data.append({ + "id": q.id, + "question_text": q.question_text, + "stimulus": q.stimulus, + "question_type": q.question_type, + "pgk_type": q.pgk_type, + "points": q.points, + "order_index": q.order_index, + "image_url": q.image_url, + "video_url": q.video_url, + "audio_url": q.audio_url, + "question_settings": q.question_settings or {}, + "options": options + }) - if not exam: - return None + serialized = json.dumps(questions_data, default=str) + await cache_manager.set(cache_key, serialized, ttl=1800) + return serialized - # Build response WITHOUT is_correct - questions_data = [] - for q in sorted(exam.questions, key=lambda x: x.order_index): - options = [ - { - "id": opt.id, - "option_text": opt.option_text, - "order_index": opt.order_index, - "option_group": opt.option_group or "standard", - "pair_id": opt.pair_id - } - for opt in sorted(q.options, key=lambda x: x.order_index) - ] - - questions_data.append({ - "id": q.id, - "question_text": q.question_text, - "stimulus": q.stimulus, - "question_type": q.question_type, - "pgk_type": q.pgk_type, - "points": q.points, - "order_index": q.order_index, - "image_url": q.image_url, - "video_url": q.video_url, - "audio_url": q.audio_url, - "question_settings": q.question_settings or {}, - "options": options - }) - - # 3. Set Cache (30 mins) - await cache_manager.set(cache_key, json.dumps(questions_data, default=str), ttl=1800) - - return questions_data + return json.loads(await _question_payload_fills.run(cache_key, fill)) diff --git a/app/utils/apk_validation.py b/app/utils/apk_validation.py index 5f31ac2..81b3491 100644 --- a/app/utils/apk_validation.py +++ b/app/utils/apk_validation.py @@ -22,6 +22,7 @@ from sqlalchemy import select from app.database import async_session_read from app.core.apk_profiles import get_token_label, parse_token_profiles +from app.core.start_db_admission import start_db_segment from app.models.system_settings import SystemSettings logger = logging.getLogger(__name__) @@ -64,14 +65,15 @@ async def _get_settings_cache() -> Dict[str, Optional[str]]: token_validation_bypass = False settings_fetch_error = False try: - async with async_session_read() as db: - settings = await db.execute(select(SystemSettings)) - result = settings.scalar_one_or_none() - if result: - minimum_token = result.minimum_apk_token - token_profiles = parse_token_profiles(result.minimum_apk_token) - allowed_tokens = token_profiles.get("tokens", []) - token_validation_bypass = bool(result.token_validation_bypass) + async with start_db_segment("security"): + async with async_session_read() as db: + settings = await db.execute(select(SystemSettings)) + result = settings.scalar_one_or_none() + if result: + minimum_token = result.minimum_apk_token + token_profiles = parse_token_profiles(result.minimum_apk_token) + allowed_tokens = token_profiles.get("tokens", []) + token_validation_bypass = bool(result.token_validation_bypass) except Exception: settings_fetch_error = True minimum_token = _settings_cache["minimum_token"] diff --git a/docker-compose.canary-api8.yml b/docker-compose.canary-api8.yml new file mode 100644 index 0000000..874718e --- /dev/null +++ b/docker-compose.canary-api8.yml @@ -0,0 +1,19 @@ +# Isolated api8 canary overlay. Do not apply while public exam traffic is live +# unless Stage 0 has marked api8 down in Nginx. +# Compose merges environment lists; volumes use !override so /app/app is not appended. +name: siab1 + +services: + api8: + environment: + - START_DB_ADMISSION_LIMIT=4 + - SIAB_REPLICA=api8 + volumes: !override + - ./uploads:/app/uploads + - ./logs:/app/logs + - ./seb_configs:/app/seb_configs + - ./static:/app/static + - ./templates:/app/templates + - /opt/siab1-canary/app:/app/app + - ./apk_builds:/app/apk_builds + - ./runtime_control:/app/runtime_control diff --git a/docker/nginx.canary-stage0.api8.line b/docker/nginx.canary-stage0.api8.line new file mode 100644 index 0000000..921f588 --- /dev/null +++ b/docker/nginx.canary-stage0.api8.line @@ -0,0 +1 @@ + server api8:8000 resolve max_fails=60 fail_timeout=5s down; diff --git a/docker/nginx.production.conf b/docker/nginx.production.conf index fbf361f..ed8a3d6 100644 --- a/docker/nginx.production.conf +++ b/docker/nginx.production.conf @@ -27,7 +27,9 @@ events { '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for" ' 'rt=$request_time uct="$upstream_connect_time" ' - 'uht="$upstream_header_time" urt="$upstream_response_time"'; + 'uht="$upstream_header_time" urt="$upstream_response_time" ' + 'ua="$upstream_addr" us="$upstream_status" ' + 'ur="$upstream_http_x_siab_replica"'; access_log /var/log/nginx/access.log main; diff --git a/tests/test_cache_fill_singleflight.py b/tests/test_cache_fill_singleflight.py new file mode 100644 index 0000000..d05b4ad --- /dev/null +++ b/tests/test_cache_fill_singleflight.py @@ -0,0 +1,383 @@ +import asyncio +from types import SimpleNamespace +from typing import Any, Callable + +import pytest + +from app.core import cache +from app.core.cache_manager import cache_manager +from app.core.singleflight import KeyedSingleFlight +from app.middleware import seb_validation +from app.services import exam_service as exam_service_module +from app.services.exam_service import ExamService +from app.utils import apk_validation + + +class _ScalarResult: + def __init__(self, value: Any): + self._value = value + + def scalar_one_or_none(self) -> Any: + return self._value + + +class _ScalarsResult: + def __init__(self, values: list[Any]): + self._values = values + + def scalars(self) -> "_ScalarsResult": + return self + + def all(self) -> list[Any]: + return self._values + + +class _SessionContext: + def __init__(self, session: Any): + self._session = session + + async def __aenter__(self) -> Any: + return self._session + + async def __aexit__(self, *_args: Any) -> None: + return None + + +class _MissBarrierRedis: + def __init__(self, callers: int): + self.callers = callers + self.get_calls = 0 + self._all_gets_started = asyncio.Event() + self.values: dict[str, Any] = {} + + async def get(self, key: str) -> Any: + self.get_calls += 1 + if self.get_calls >= self.callers: + self._all_gets_started.set() + await self._all_gets_started.wait() + return self.values.get(key) + + async def set(self, key: str, value: Any, **_kwargs: Any) -> bool: + self.values[key] = value + return True + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("function_name", "expected_result"), + [ + ("is_developer_mode_enabled", False), + ("is_freeze_mode_enabled", False), + ("get_allowed_signatures", []), + ], +) +async def test_security_cache_cold_burst_executes_one_db_fill( + monkeypatch, + function_name: str, + expected_result: Any, +) -> None: + callers = 20 + redis = _MissBarrierRedis(callers) + loader_calls = 0 + setting = SimpleNamespace( + allow_browser_testing=False, + freeze_mode=False, + allowed_signatures=None, + ) + + class FakeSession: + async def execute(self, _statement: Any) -> _ScalarResult: + nonlocal loader_calls + loader_calls += 1 + await asyncio.sleep(0.02) + return _ScalarResult(setting) + + async def fake_get_redis() -> _MissBarrierRedis: + return redis + + monkeypatch.setattr(cache, "get_redis", fake_get_redis) + monkeypatch.setattr( + cache, + "async_session_read", + lambda: _SessionContext(FakeSession()), + ) + + cache_function: Callable[[], Any] = getattr(cache, function_name) + results = await asyncio.gather(*(cache_function() for _ in range(callers))) + + assert results == [expected_result] * callers + assert loader_calls == 1 + + +@pytest.mark.asyncio +async def test_question_payload_cold_burst_executes_one_db_fill(monkeypatch) -> None: + callers = 20 + redis = _MissBarrierRedis(callers) + loader_calls = 0 + cache_writes = 0 + question = SimpleNamespace( + id=11, + question_text="Question", + stimulus=None, + question_type="multiple_choice", + pgk_type=None, + points=1, + order_index=1, + image_url=None, + video_url=None, + audio_url=None, + question_settings={}, + options=[], + ) + + class FakeDb: + async def execute(self, _statement: Any) -> _ScalarsResult: + nonlocal loader_calls + loader_calls += 1 + await asyncio.sleep(0.02) + return _ScalarsResult([question]) + + async def fake_cache_get(key: str) -> Any: + return await redis.get(key) + + async def fake_cache_set(key: str, value: str, ttl: int) -> bool: + nonlocal cache_writes + cache_writes += 1 + redis.values[key] = value + return True + + monkeypatch.setattr(cache_manager, "get", fake_cache_get) + monkeypatch.setattr(cache_manager, "set", fake_cache_set) + monkeypatch.setattr( + exam_service_module, + "async_session_read", + lambda: _SessionContext(FakeDb()), + ) + + services = [ExamService(FakeDb()) for _ in range(callers)] + results = await asyncio.gather( + *(service.get_questions_payload(91) for service in services) + ) + + assert all(result[0]["id"] == 11 for result in results) + assert loader_calls == 1 + assert cache_writes == 1 + + redis.values.clear() + second_generation = await asyncio.gather( + *(service.get_questions_payload(91) for service in services) + ) + + assert all(result[0]["id"] == 11 for result in second_generation) + assert loader_calls == 2 + assert cache_writes == 2 + + +@pytest.mark.asyncio +async def test_allow_mobile_local_cache_already_deduplicates_cold_fill( + monkeypatch, +) -> None: + loader_calls = 0 + + class FakeSession: + async def execute(self, _statement: Any) -> _ScalarResult: + nonlocal loader_calls + loader_calls += 1 + await asyncio.sleep(0.02) + return _ScalarResult(True) + + monkeypatch.setattr( + "app.database.async_session_read", + lambda: _SessionContext(FakeSession()), + ) + monkeypatch.setattr( + seb_validation, + "_allow_mobile_cache", + {"expires_at": 0.0, "allow_mobile": True}, + ) + monkeypatch.setattr(seb_validation, "_allow_mobile_cache_lock", asyncio.Lock()) + + results = await asyncio.gather( + *(seb_validation._get_allow_mobile_apps_cached() for _ in range(20)) + ) + + assert results == [True] * 20 + assert loader_calls == 1 + + +@pytest.mark.asyncio +async def test_apk_settings_local_cache_already_deduplicates_cold_fill( + monkeypatch, +) -> None: + loader_calls = 0 + setting = SimpleNamespace( + minimum_apk_token=None, + token_validation_bypass=False, + ) + + class FakeSession: + async def execute(self, _statement: Any) -> _ScalarResult: + nonlocal loader_calls + loader_calls += 1 + await asyncio.sleep(0.02) + return _ScalarResult(setting) + + monkeypatch.setattr( + apk_validation, + "async_session_read", + lambda: _SessionContext(FakeSession()), + ) + monkeypatch.setattr( + apk_validation, + "_settings_cache", + { + "expires_at": 0.0, + "minimum_token": None, + "allowed_tokens": [], + "token_profiles": { + "stable": None, + "new_update": None, + "tokens": [], + "labels_by_token": {}, + }, + "token_validation_bypass": False, + "settings_fetch_error": False, + }, + ) + monkeypatch.setattr(apk_validation, "_settings_cache_lock", asyncio.Lock()) + + results = await asyncio.gather( + *(apk_validation._get_settings_cache() for _ in range(20)) + ) + + assert all(result["settings_fetch_error"] is False for result in results) + assert loader_calls == 1 + + +@pytest.mark.asyncio +async def test_singleflight_different_keys_do_not_block_each_other() -> None: + singleflight = KeyedSingleFlight[str]() + both_started = asyncio.Event() + release = asyncio.Event() + started: set[str] = set() + + async def load(key: str) -> str: + started.add(key) + if len(started) == 2: + both_started.set() + await release.wait() + return key + + tasks = [ + asyncio.create_task(singleflight.run(key, lambda key=key: load(key))) + for key in ("exam-a", "exam-b") + ] + await asyncio.wait_for(both_started.wait(), timeout=1) + release.set() + + assert await asyncio.gather(*tasks) == ["exam-a", "exam-b"] + + +@pytest.mark.asyncio +async def test_singleflight_loader_exception_reaches_waiters_and_allows_retry() -> None: + singleflight = KeyedSingleFlight[str]() + loader_started = asyncio.Event() + release = asyncio.Event() + loader_calls = 0 + + async def failing_loader() -> str: + nonlocal loader_calls + loader_calls += 1 + loader_started.set() + await release.wait() + raise RuntimeError("loader failed") + + tasks = [ + asyncio.create_task(singleflight.run("key", failing_loader)) + for _ in range(20) + ] + await loader_started.wait() + await asyncio.sleep(0) + release.set() + results = await asyncio.gather(*tasks, return_exceptions=True) + + assert loader_calls == 1 + assert all(isinstance(result, RuntimeError) for result in results) + assert await singleflight.run("key", lambda: _return_value("retry")) == "retry" + + +@pytest.mark.asyncio +async def test_singleflight_loader_cancellation_clears_marker_for_retry() -> None: + singleflight = KeyedSingleFlight[str]() + loader_started = asyncio.Event() + release = asyncio.Event() + loader_calls = 0 + + async def cancelled_loader() -> str: + nonlocal loader_calls + loader_calls += 1 + loader_started.set() + await release.wait() + raise asyncio.CancelledError + + tasks = [ + asyncio.create_task(singleflight.run("key", cancelled_loader)) + for _ in range(20) + ] + await loader_started.wait() + await asyncio.sleep(0) + release.set() + results = await asyncio.gather(*tasks, return_exceptions=True) + + assert loader_calls == 1 + assert all(isinstance(result, asyncio.CancelledError) for result in results) + assert await singleflight.run("key", lambda: _return_value("retry")) == "retry" + + +@pytest.mark.asyncio +async def test_singleflight_waiter_cancellation_does_not_cancel_loader() -> None: + singleflight = KeyedSingleFlight[str]() + loader_started = asyncio.Event() + release = asyncio.Event() + + async def loader() -> str: + loader_started.set() + await release.wait() + return "value" + + owner = asyncio.create_task(singleflight.run("key", loader)) + await loader_started.wait() + waiter = asyncio.create_task(singleflight.run("key", loader)) + await asyncio.sleep(0) + waiter.cancel() + + with pytest.raises(asyncio.CancelledError): + await waiter + release.set() + assert await owner == "value" + + +@pytest.mark.asyncio +async def test_singleflight_waiter_timeout_does_not_poison_loader() -> None: + singleflight = KeyedSingleFlight[str]() + loader_started = asyncio.Event() + release = asyncio.Event() + + async def loader() -> str: + loader_started.set() + await release.wait() + return "value" + + owner = asyncio.create_task(singleflight.run("key", loader)) + await loader_started.wait() + + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(singleflight.run("key", loader), timeout=0.01) + + release.set() + assert await owner == "value" + assert await singleflight.run("key", lambda: _return_value("retry")) == "retry" + + +async def _return_value(value: str) -> str: + return value diff --git a/tests/test_canary_api8_isolation.py b/tests/test_canary_api8_isolation.py new file mode 100644 index 0000000..706b16c --- /dev/null +++ b/tests/test_canary_api8_isolation.py @@ -0,0 +1,122 @@ +from pathlib import Path +import json +import os +import re +import shutil +import subprocess + + +ROOT = Path(__file__).resolve().parents[1] +PRODUCTION_COMPOSE = (ROOT / "docker-compose.production.yml").read_text(encoding="utf-8") +CANARY_COMPOSE = (ROOT / "docker-compose.canary-api8.yml").read_text(encoding="utf-8") +NGINX_CONF = (ROOT / "docker" / "nginx.production.conf").read_text(encoding="utf-8") +STAGE0_API8 = (ROOT / "docker" / "nginx.canary-stage0.api8.line").read_text(encoding="utf-8") + +CONTROL_SERVICES = ("api", "api2", "api3", "api4", "api5", "api6", "api7") +API_VOLUMES = ( + "./uploads:/app/uploads", + "./logs:/app/logs", + "./seb_configs:/app/seb_configs", + "./static:/app/static", + "./templates:/app/templates", + "./apk_builds:/app/apk_builds", + "./runtime_control:/app/runtime_control", +) + + +def test_canary_override_targets_only_api8() -> None: + assert "api8:" in CANARY_COMPOSE + for name in CONTROL_SERVICES + ("api_admin", "api_admin2"): + assert re.search(rf"^\s+{name}:", CANARY_COMPOSE, re.MULTILINE) is None + + +def test_canary_api8_replaces_app_mount_and_pins_n4() -> None: + assert "volumes: !override" in CANARY_COMPOSE + assert "/opt/siab1-canary/app:/app/app" in CANARY_COMPOSE + assert "./app:/app/app" not in CANARY_COMPOSE + assert "START_DB_ADMISSION_LIMIT=4" in CANARY_COMPOSE + assert "SIAB_REPLICA=api8" in CANARY_COMPOSE + for volume in API_VOLUMES: + assert volume in CANARY_COMPOSE + + +def test_production_control_plane_keeps_shared_app_mount() -> None: + assert "./app:/app/app" in PRODUCTION_COMPOSE + assert "START_DB_ADMISSION_LIMIT" not in PRODUCTION_COMPOSE + assert "/opt/siab1-canary/app:/app/app" not in PRODUCTION_COMPOSE + + +def test_nginx_logs_upstream_identity_and_status_chain() -> None: + assert "$upstream_addr" in NGINX_CONF + assert "$upstream_status" in NGINX_CONF + assert "$upstream_response_time" in NGINX_CONF + assert "$upstream_http_x_siab_replica" in NGINX_CONF + + +def test_production_api8_is_not_down_by_default() -> None: + match = re.search(r"server api8:8000[^\n]*", NGINX_CONF) + assert match is not None + assert " down" not in match.group(0) + + +def test_stage0_snippet_marks_api8_down() -> None: + assert "server api8:8000" in STAGE0_API8 + assert " down" in STAGE0_API8 + assert "api_admin" not in STAGE0_API8 + + +def test_resolved_compose_isolates_api8_app_mount() -> None: + if shutil.which("docker") is None: + return + env = os.environ.copy() + env.setdefault("SECRET_KEY", "canary-config-placeholder") + env.setdefault("JWT_SECRET_KEY", "canary-config-jwt-placeholder") + completed = subprocess.run( + [ + "docker", + "compose", + "-f", + str(ROOT / "docker-compose.production.yml"), + "-f", + str(ROOT / "docker-compose.canary-api8.yml"), + "config", + "--format", + "json", + ], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + assert completed.returncode == 0, completed.stderr + services = json.loads(completed.stdout)["services"] + + def app_sources(name: str) -> list[str]: + return [ + str(volume.get("source") or "") + for volume in services[name].get("volumes") or [] + if volume.get("target") == "/app/app" + ] + + def env_map(name: str) -> dict[str, str]: + value = services[name].get("environment") or {} + if isinstance(value, dict): + return {str(key): str(item) for key, item in value.items()} + parsed: dict[str, str] = {} + for item in value: + key, _, raw = str(item).partition("=") + parsed[key] = raw + return parsed + + assert app_sources("api8") == ["/opt/siab1-canary/app"] + assert env_map("api8")["START_DB_ADMISSION_LIMIT"] == "4" + assert env_map("api8")["SIAB_REPLICA"] == "api8" + for name in ("api", "api2", "api3", "api4", "api5", "api6", "api7", "api_admin", "api_admin2"): + sources = app_sources(name) + assert len(sources) == 1 + assert sources[0].endswith("/app") + assert "/opt/siab1-canary/app" not in sources + mapped = env_map(name) + assert mapped.get("START_DB_ADMISSION_LIMIT") != "4" + assert mapped.get("SIAB_REPLICA") != "api8" diff --git a/tests/test_exam_hotpath_query_fanout.py b/tests/test_exam_hotpath_query_fanout.py new file mode 100644 index 0000000..9c3a66c --- /dev/null +++ b/tests/test_exam_hotpath_query_fanout.py @@ -0,0 +1,160 @@ +from pathlib import Path +import re + +from sqlalchemy import select +from sqlalchemy.orm import joinedload, noload, selectinload + +from app.models.exam import Exam +from app.models.question import Question +from app.models.session import Answer, ExamLog, ExamSession +from app.models.user import User + + +EXAMS_SOURCE = Path("app/api/exams.py").read_text(encoding="utf-8") +EXAM_SERVICE_SOURCE = Path("app/services/exam_service.py").read_text(encoding="utf-8") +ANSWER_SYNC_SOURCE = Path("app/services/answer_sync_service.py").read_text(encoding="utf-8") +ANSWER_SYNC_API_SOURCE = Path("app/api/exam_answer_sync.py").read_text(encoding="utf-8") +SECURITY_SOURCE = Path("app/core/security.py").read_text(encoding="utf-8") +RUNTIME_BUFFER_SOURCE = Path("app/services/answer_runtime_buffer.py").read_text(encoding="utf-8") +FINAL_SUBMIT_SOURCE = Path("app/services/final_submit_service.py").read_text(encoding="utf-8") + + +def _extract_async_function(source: str, function_name: str) -> str: + pattern = re.compile( + rf"async def {re.escape(function_name)}\([\s\S]*?(?=\n@router|\n async def |\nasync def |\n def |\ndef |\nclass |\Z)", + re.MULTILINE, + ) + match = pattern.search(source) + assert match is not None, f"Function {function_name} not found" + return match.group(0) + + +def _selectin_keys(model) -> set[str]: + return { + relationship.key + for relationship in model.__mapper__.relationships + if relationship.lazy == "selectin" + } + + +def test_global_selectin_mappings_are_unchanged() -> None: + assert _selectin_keys(Exam) == {"creator", "questions", "sessions"} + assert _selectin_keys(User) == {"created_exams", "exam_sessions"} + assert _selectin_keys(ExamSession) == {"user", "exam", "answers", "logs"} + assert _selectin_keys(Question) >= {"exam", "category", "tags", "options", "answers"} + assert _selectin_keys(Answer) == {"session", "question"} + assert _selectin_keys(ExamLog) == {"session"} + + +def test_join_exam_blocks_implicit_exam_graph() -> None: + fn = _extract_async_function(EXAMS_SOURCE, "join_exam_by_token") + assert '.options(noload("*"))' in fn + assert "select(func.count(ExamSession.id))" in fn + assert "select(func.count(Question.id))" in fn + assert "selectinload(Exam.questions)" not in fn + assert "selectinload(Exam.sessions)" not in fn + + +def test_start_session_row_queries_block_implicit_graph() -> None: + fn = _extract_async_function(EXAMS_SOURCE, "start_exam_session") + assert "get_exam_start_session_state" in fn + assert "select(func.count(ExamSession.id))" not in fn + assert fn.count('.options(noload("*"))') >= 2 + assert "selectinload(ExamSession.answers)" not in fn + assert "selectinload(ExamSession.user)" not in fn + assert "selectinload(ExamSession.exam)" not in fn + assert "selectinload(ExamSession.logs)" not in fn + + +def test_exam_settings_query_loads_creator_only() -> None: + fn = _extract_async_function(EXAM_SERVICE_SOURCE, "get_exam_with_settings") + assert 'noload("*")' in fn + assert "joinedload(Exam.creator)" in fn + assert "selectinload(Exam.questions)" not in fn + assert "selectinload(Exam.sessions)" not in fn + + +def test_start_uses_live_column_projection() -> None: + start_fn = _extract_async_function(EXAMS_SOURCE, "start_exam_session") + projection_fn = _extract_async_function( + EXAM_SERVICE_SOURCE, + "get_exam_start_projection", + ) + assert "get_exam_start_projection" in start_fn + assert "get_exam_start_session_state" in start_fn + assert "get_exam_with_settings" not in start_fn + assert "_get_exam_creator_role" not in start_fn + assert "password_hash" not in projection_fn + assert "seb_config_key" not in projection_fn + assert "builder_settings" not in projection_fn + assert "Exam.is_published" in projection_fn + assert "Exam.start_time" in projection_fn + assert "Exam.end_time" in projection_fn + assert "Exam.max_attempts" in projection_fn + assert "Exam.allowed_classes" in projection_fn + assert "User.role" in projection_fn + assert "User.full_name" in projection_fn + assert "is_globally_paused" not in start_fn + assert "is_globally_paused" not in projection_fn + + +def test_question_payload_query_does_not_reuse_exam_collection() -> None: + fn = _extract_async_function(EXAM_SERVICE_SOURCE, "get_questions_payload") + assert "select(Question)" in fn + assert "selectinload(Question.options)" in fn + assert 'noload("*")' in fn + assert "select(Exam)" not in fn + assert "selectinload(Exam.questions)" not in fn + + +def test_answer_lock_and_autosave_queries_are_row_only() -> None: + lock_fn = _extract_async_function(ANSWER_SYNC_SOURCE, "_lock_session_for_single_answer") + autosave_fn = _extract_async_function(ANSWER_SYNC_SOURCE, "accept_legacy_autosave") + batch_fn = _extract_async_function(ANSWER_SYNC_SOURCE, "accept_batch") + ensure_fn = _extract_async_function(ANSWER_SYNC_SOURCE, "_ensure_session_in_progress_for_user") + for fn in (lock_fn, autosave_fn, batch_fn, ensure_fn): + assert '.options(noload("*"))' in fn + assert "selectinload(ExamSession" not in fn + + +def test_session_answer_restore_query_is_row_only() -> None: + fn = _extract_async_function(ANSWER_SYNC_API_SOURCE, "get_session_answers") + assert fn.count('.options(noload("*"))') >= 1 + assert 'select(Answer).options(noload("*"))' in fn + + +def test_join_auth_lookup_does_not_load_user_collections() -> None: + fn = _extract_async_function(SECURITY_SOURCE, "_resolve_authenticated_user") + assert 'select(User).options(noload("*"))' in fn + + +def test_final_submit_keeps_explicit_grading_graph() -> None: + fn = _extract_async_function(FINAL_SUBMIT_SOURCE, "_load_session_for_finalize") + assert 'noload("*")' in fn + assert "selectinload(ExamSession.exam)" in fn + assert "selectinload(Exam.questions)" in fn + assert "selectinload(Question.options)" in fn + assert "selectinload(ExamSession.answers)" in fn + + +def test_runtime_buffer_session_queries_are_row_only() -> None: + assert RUNTIME_BUFFER_SOURCE.count('.options(noload("*"))') >= 3 + + +def test_hotpath_select_options_compile() -> None: + exam_stmt = ( + select(Exam) + .options(noload("*"), joinedload(Exam.creator).options(noload("*"))) + .where(Exam.id == 1) + ) + question_stmt = ( + select(Question) + .options(noload("*"), selectinload(Question.options).options(noload("*"))) + .where(Question.exam_id == 1) + ) + session_stmt = select(ExamSession).options(noload("*")).where(ExamSession.id == 1) + assert exam_stmt._with_options + assert question_stmt._with_options + assert session_stmt._with_options + compiled = str(session_stmt.compile()) + assert "exam_sessions" in compiled.lower() diff --git a/tests/test_exam_start_live_projection.py b/tests/test_exam_start_live_projection.py new file mode 100644 index 0000000..1e0d553 --- /dev/null +++ b/tests/test_exam_start_live_projection.py @@ -0,0 +1,221 @@ +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from typing import Any, Optional + +import pytest +from fastapi import HTTPException +from starlette.requests import Request + +from app.api import exams +from app.services.exam_service import ( + ExamStartCreatorView, + ExamStartProjection, + ExamStartSessionState, +) + + +class FakeResult: + def __init__(self, *, scalar_value: Any = None, rows: list[Any] | None = None) -> None: + self.scalar_value = scalar_value + self.rows = rows or [] + + def scalar(self) -> Any: + return self.scalar_value + + def scalars(self) -> "FakeResult": + return self + + def all(self) -> list[Any]: + return self.rows + + +class FakeSession: + def __init__(self, results: Optional[list[FakeResult]] = None) -> None: + self.results = iter(results or []) + + async def execute(self, _statement: Any) -> FakeResult: + return next(self.results) + + +def _projection(**overrides: Any) -> ExamStartProjection: + now = datetime.now(timezone.utc) + values: dict[str, Any] = { + "id": 7, + "creator_id": 11, + "is_published": True, + "start_time": now - timedelta(minutes=5), + "end_time": now + timedelta(minutes=55), + "max_attempts": 1, + "allowed_classes": "XII", + "allowed_students": None, + "duration_minutes": 60, + "shuffle_questions": False, + "shuffle_options": False, + "title": "Ujian", + "subject": "MTK", + "exam_type": "UH", + "show_results": False, + "show_teacher_name": True, + "creator": ExamStartCreatorView(full_name="Guru", role="teacher"), + } + values.update(overrides) + return ExamStartProjection(**values) + + +def _request() -> Request: + return Request( + { + "type": "http", + "method": "POST", + "path": "/api/exams/7/start", + "headers": [], + "client": ("127.0.0.1", 1234), + "scheme": "http", + "server": ("testserver", 80), + } + ) + + +def _patch_start_deps( + monkeypatch: pytest.MonkeyPatch, + projection: ExamStartProjection, +) -> None: + class FakeExamService: + def __init__(self, _db: Any) -> None: + pass + + async def get_exam_start_projection(self, _exam_id: int) -> ExamStartProjection: + return projection + + async def get_exam_start_session_state( + self, + _user_id: int, + _exam_id: int, + ) -> ExamStartSessionState: + return ExamStartSessionState(attempt_count=0, existing_sessions=[]) + + async def no_op(*_args: Any, **_kwargs: Any) -> None: + return None + + monkeypatch.setattr(exams, "ExamService", FakeExamService) + monkeypatch.setattr(exams, "validate_seb_headers", no_op) + monkeypatch.setattr(exams, "_ensure_exam_start_option_integrity", no_op) + + +async def _start(projection: ExamStartProjection, user: Any, db: FakeSession) -> Any: + return await exams.start_exam_session(7, _request(), user, db) + + +@pytest.mark.asyncio +async def test_start_rejects_unpublished_projection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_start_deps(monkeypatch, _projection(is_published=False)) + user = SimpleNamespace(id=5, role="student", username="s", student_class="XII") + with pytest.raises(HTTPException) as exc: + await _start(_projection(is_published=False), user, FakeSession()) + assert exc.value.status_code == 400 + assert "dipublikasikan" in str(exc.value.detail).lower() + + +@pytest.mark.asyncio +async def test_start_rejects_future_window(monkeypatch: pytest.MonkeyPatch) -> None: + now = datetime.now(timezone.utc) + projection = _projection( + start_time=now + timedelta(minutes=10), + end_time=now + timedelta(minutes=70), + ) + _patch_start_deps(monkeypatch, projection) + user = SimpleNamespace(id=5, role="student", username="s", student_class="XII") + with pytest.raises(HTTPException) as exc: + await _start(projection, user, FakeSession()) + assert exc.value.status_code == 400 + assert "belum dimulai" in str(exc.value.detail).lower() + + +@pytest.mark.asyncio +async def test_start_rejects_ended_window(monkeypatch: pytest.MonkeyPatch) -> None: + now = datetime.now(timezone.utc) + projection = _projection( + start_time=now - timedelta(minutes=70), + end_time=now - timedelta(minutes=10), + ) + _patch_start_deps(monkeypatch, projection) + user = SimpleNamespace(id=5, role="student", username="s", student_class="XII") + with pytest.raises(HTTPException) as exc: + await _start(projection, user, FakeSession()) + assert exc.value.status_code == 400 + assert "sudah berakhir" in str(exc.value.detail).lower() + + +@pytest.mark.asyncio +async def test_start_uses_live_max_attempts(monkeypatch: pytest.MonkeyPatch) -> None: + projection = _projection(max_attempts=1) + class FakeExamService: + def __init__(self, _db: Any) -> None: + pass + + async def get_exam_start_projection(self, _exam_id: int) -> ExamStartProjection: + return projection + + async def get_exam_start_session_state( + self, + _user_id: int, + _exam_id: int, + ) -> ExamStartSessionState: + return ExamStartSessionState(attempt_count=1, existing_sessions=[]) + + async def no_op(*_args: Any, **_kwargs: Any) -> None: + return None + + monkeypatch.setattr(exams, "ExamService", FakeExamService) + monkeypatch.setattr(exams, "validate_seb_headers", no_op) + monkeypatch.setattr(exams, "_ensure_exam_start_option_integrity", no_op) + user = SimpleNamespace(id=5, role="student", username="s", student_class="XII") + with pytest.raises(HTTPException) as exc: + await _start(projection, user, FakeSession()) + assert exc.value.status_code == 400 + assert "percobaan" in str(exc.value.detail).lower() + + +@pytest.mark.asyncio +async def test_start_uses_live_allow_list(monkeypatch: pytest.MonkeyPatch) -> None: + projection = _projection(allowed_classes="X", allowed_students=None) + _patch_start_deps(monkeypatch, projection) + user = SimpleNamespace(id=5, role="student", username="s", student_class="XII") + with pytest.raises(HTTPException) as exc: + await _start(projection, user, FakeSession()) + assert exc.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_start_uses_live_creator_role_for_guruplus( + monkeypatch: pytest.MonkeyPatch, +) -> None: + projection = _projection( + allowed_classes="GuruPlus", + creator=ExamStartCreatorView(full_name="Guru", role="teacher"), + ) + _patch_start_deps(monkeypatch, projection) + user = SimpleNamespace( + id=5, + role="guruplus", + username="gp", + student_class="GuruPlus", + ) + with pytest.raises(HTTPException) as exc: + await _start(projection, user, FakeSession()) + assert exc.value.status_code == 403 + assert "developer" in str(exc.value.detail).lower() + + +def test_start_projection_has_no_process_cache() -> None: + source = __import__("pathlib").Path("app/services/exam_service.py").read_text( + encoding="utf-8" + ) + projection_fn = source.split("async def get_exam_start_projection")[1].split( + "async def ", + 1, + )[0] + assert "cache" not in projection_fn.lower() + assert "_exam_start" not in projection_fn diff --git a/tests/test_exam_start_recovery_guard.py b/tests/test_exam_start_recovery_guard.py index 0965c38..7d6d67f 100644 --- a/tests/test_exam_start_recovery_guard.py +++ b/tests/test_exam_start_recovery_guard.py @@ -7,6 +7,7 @@ from starlette.requests import Request from app.api import exams +from app.services.exam_service import ExamStartSessionState class FakeResult: @@ -47,6 +48,7 @@ async def test_start_blocks_replacement_after_admin_termination( start_time=now - timedelta(minutes=5), end_time=now + timedelta(minutes=55), max_attempts=2, + creator=SimpleNamespace(role="teacher", full_name="Guru"), ) terminated_session = SimpleNamespace( id=41, @@ -55,21 +57,25 @@ async def test_start_blocks_replacement_after_admin_termination( terminated_by_admin=True, violation_count=0, ) - db = FakeSession( - [ - FakeResult(scalar_value=0), - FakeResult(rows=[terminated_session]), - FakeResult(rows=[]), - ] - ) + db = FakeSession([FakeResult(rows=[])]) class FakeExamService: def __init__(self, _db: FakeSession) -> None: pass - async def get_exam_with_settings(self, _exam_id: int) -> Any: + async def get_exam_start_projection(self, _exam_id: int) -> Any: return exam + async def get_exam_start_session_state( + self, + _user_id: int, + _exam_id: int, + ) -> ExamStartSessionState: + return ExamStartSessionState( + attempt_count=0, + existing_sessions=[terminated_session], + ) + async def no_op(*_args: Any, **_kwargs: Any) -> None: return None diff --git a/tests/test_exam_start_session_state.py b/tests/test_exam_start_session_state.py new file mode 100644 index 0000000..8b8fed2 --- /dev/null +++ b/tests/test_exam_start_session_state.py @@ -0,0 +1,526 @@ +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from typing import Any, Optional + +import pytest +import sqlalchemy +from fastapi import HTTPException +from sqlalchemy.dialects import postgresql +from starlette.requests import Request + +from app.api import exams +from app.services.exam_service import ( + COMPLETED_ATTEMPT_STATUSES, + START_EXISTING_SESSION_LIMIT, + START_EXISTING_SESSION_STATUSES, + ExamService, + ExamStartCreatorView, + ExamStartProjection, + ExamStartSessionRow, + ExamStartSessionState, + build_exam_start_session_state_statement, +) + + +class FakeResult: + def __init__(self, *, scalar_value: Any = None, rows: list[Any] | None = None) -> None: + self.scalar_value = scalar_value + self.rows = rows or [] + + def scalar(self) -> Any: + return self.scalar_value + + def scalars(self) -> "FakeResult": + return self + + def all(self) -> list[Any]: + return self.rows + + def scalar_one_or_none(self) -> Any: + return self.rows[0] if self.rows else None + + +class FakeSession: + def __init__(self, results: Optional[list[FakeResult]] = None) -> None: + self.results = iter(results or []) + self.added: list[Any] = [] + self.commits = 0 + self.rollbacks = 0 + self.flush_error: Optional[BaseException] = None + + async def execute(self, _statement: Any) -> FakeResult: + try: + return next(self.results) + except StopIteration: + return FakeResult() + + def add(self, value: Any) -> None: + self.added.append(value) + + async def flush(self) -> None: + if self.flush_error is not None: + raise self.flush_error + + async def commit(self) -> None: + self.commits += 1 + + async def rollback(self) -> None: + self.rollbacks += 1 + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _projection(**overrides: Any) -> ExamStartProjection: + now = _now() + values: dict[str, Any] = { + "id": 7, + "creator_id": 11, + "is_published": True, + "start_time": now - timedelta(minutes=5), + "end_time": now + timedelta(minutes=55), + "max_attempts": 1, + "allowed_classes": None, + "allowed_students": None, + "duration_minutes": 60, + "shuffle_questions": False, + "shuffle_options": False, + "title": "Ujian", + "subject": "MTK", + "exam_type": "UH", + "show_results": False, + "show_teacher_name": True, + "creator": ExamStartCreatorView(full_name="Guru", role="teacher"), + } + values.update(overrides) + return ExamStartProjection(**values) + + +def _session_row(**overrides: Any) -> ExamStartSessionRow: + values: dict[str, Any] = { + "id": 41, + "status": "in_progress", + "start_time": _now() - timedelta(minutes=3), + "end_time": None, + "terminated_by_admin": False, + "emergency_exit_allowed": False, + "violation_count": 0, + "total_paused_seconds": 0, + } + values.update(overrides) + return ExamStartSessionRow(**values) + + +def _request() -> Request: + return Request( + { + "type": "http", + "method": "POST", + "path": "/api/exams/7/start", + "headers": [], + "client": ("127.0.0.1", 1234), + "scheme": "http", + "server": ("testserver", 80), + } + ) + + +def _user(**overrides: Any) -> SimpleNamespace: + values = { + "id": 5, + "role": "student", + "username": "student", + "student_class": "XII", + } + values.update(overrides) + return SimpleNamespace(**values) + + +def _patch_start( + monkeypatch: pytest.MonkeyPatch, + *, + projection: ExamStartProjection, + state: ExamStartSessionState, + questions: Optional[list[dict[str, Any]]] = None, +) -> None: + payload = questions if questions is not None else [ + { + "id": 1, + "question_text": "Q1", + "question_type": "multiple_choice", + "points": 1, + "order_index": 1, + "options": [{"id": 11, "option_text": "A", "order_index": 1}], + } + ] + + class FakeExamService: + def __init__(self, _db: Any) -> None: + pass + + async def get_exam_start_projection(self, _exam_id: int) -> ExamStartProjection: + return projection + + async def get_exam_start_session_state( + self, + _user_id: int, + _exam_id: int, + ) -> ExamStartSessionState: + return state + + async def get_questions_payload(self, _exam_id: int) -> list[dict[str, Any]]: + return payload + + async def no_op(*_args: Any, **_kwargs: Any) -> None: + return None + + async def no_data(*_args: Any, **_kwargs: Any) -> None: + return None + + monkeypatch.setattr(exams, "ExamService", FakeExamService) + monkeypatch.setattr(exams, "validate_seb_headers", no_op) + monkeypatch.setattr(exams, "_ensure_exam_start_option_integrity", no_op) + monkeypatch.setattr(exams, "_ensure_exam_participant_access", lambda *_a, **_k: None) + monkeypatch.setattr( + exams, + "get_client_info", + lambda _request: { + "ip_address": "127.0.0.1", + "user_agent": "test", + "seb_detected": False, + }, + ) + monkeypatch.setattr(exams, "get_session_data", no_data) + monkeypatch.setattr(exams, "store_session_data", no_op) + monkeypatch.setattr(exams, "_publish_exam_monitor_event", no_op) + monkeypatch.setattr(exams, "create_session_poll_token", lambda **_k: "tok") + + +def test_merged_statement_is_one_index_friendly_select() -> None: + stmt = build_exam_start_session_state_statement(5, 7) + sql = str( + stmt.compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ).lower() + assert sql.count("select") >= 3 + assert "count(" in sql + assert "left outer join" in sql + assert "limit" in sql + assert "completed" in sql + assert "submitted" in sql + assert "in_progress" in sql + assert "terminated" in sql + assert "start_time" in sql + assert "password" not in sql + assert "for update" not in sql + assert "selectin" not in sql + + +def test_status_sets_stay_disjoint() -> None: + assert set(COMPLETED_ATTEMPT_STATUSES).isdisjoint(START_EXISTING_SESSION_STATUSES) + assert START_EXISTING_SESSION_LIMIT == 16 + + +@pytest.mark.asyncio +async def test_state_parser_keeps_count_when_no_existing_rows() -> None: + row = SimpleNamespace( + attempt_count=2, + id=None, + status=None, + start_time=None, + end_time=None, + terminated_by_admin=None, + emergency_exit_allowed=None, + violation_count=None, + total_paused_seconds=None, + ) + service = ExamService(FakeSession([FakeResult(rows=[row])])) + state = await service.get_exam_start_session_state(5, 7) + assert state.attempt_count == 2 + assert state.existing_sessions == [] + + +@pytest.mark.asyncio +async def test_state_parser_keeps_existing_order() -> None: + newer = SimpleNamespace( + attempt_count=1, + id=9, + status="in_progress", + start_time=_now(), + end_time=None, + terminated_by_admin=False, + emergency_exit_allowed=False, + violation_count=0, + total_paused_seconds=0, + ) + older = SimpleNamespace( + attempt_count=1, + id=8, + status="terminated", + start_time=_now() - timedelta(hours=1), + end_time=_now(), + terminated_by_admin=False, + emergency_exit_allowed=False, + violation_count=1, + total_paused_seconds=12, + ) + service = ExamService(FakeSession([FakeResult(rows=[newer, older])])) + state = await service.get_exam_start_session_state(5, 7) + assert state.attempt_count == 1 + assert [row.id for row in state.existing_sessions] == [9, 8] + assert state.existing_sessions[1].total_paused_seconds == 12 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("attempt_count", "max_attempts"), + [ + (1, 1), + (2, 1), + (3, 3), + (4, 3), + ], +) +async def test_max_attempts_blocks_before_resume( + monkeypatch: pytest.MonkeyPatch, + attempt_count: int, + max_attempts: int, +) -> None: + if attempt_count < max_attempts: + pytest.skip("not a blocking fixture") + _patch_start( + monkeypatch, + projection=_projection(max_attempts=max_attempts), + state=ExamStartSessionState( + attempt_count=attempt_count, + existing_sessions=[_session_row(status="in_progress")], + ), + ) + with pytest.raises(HTTPException) as exc: + await exams.start_exam_session(7, _request(), _user(), FakeSession()) + assert exc.value.status_code == 400 + assert "percobaan" in str(exc.value.detail).lower() + + +@pytest.mark.asyncio +async def test_zero_prior_attempts_creates_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_start( + monkeypatch, + projection=_projection(max_attempts=1), + state=ExamStartSessionState(attempt_count=0, existing_sessions=[]), + ) + created = SimpleNamespace( + id=99, + status="in_progress", + start_time=_now(), + end_time=None, + violation_count=0, + total_paused_seconds=0, + ) + + class CreatingSession(FakeSession): + def add(self, value: Any) -> None: + super().add(value) + if getattr(value, "user_id", None) == 5: + value.id = created.id + value.start_time = created.start_time + value.status = created.status + value.violation_count = 0 + value.total_paused_seconds = 0 + + db = CreatingSession() + response = await exams.start_exam_session(7, _request(), _user(), db) + assert response.session_id == 99 + assert any(getattr(item, "event_type", None) == "SESSION_START" for item in db.added) + + +@pytest.mark.asyncio +async def test_one_completed_below_max_creates_new( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_start( + monkeypatch, + projection=_projection(max_attempts=2), + state=ExamStartSessionState(attempt_count=1, existing_sessions=[]), + ) + + class CreatingSession(FakeSession): + def add(self, value: Any) -> None: + super().add(value) + if getattr(value, "exam_id", None) == 7: + value.id = 100 + value.start_time = _now() + value.violation_count = 0 + value.total_paused_seconds = 0 + + response = await exams.start_exam_session(7, _request(), _user(), CreatingSession()) + assert response.session_id == 100 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", ["in_progress", "active"]) +async def test_resume_existing_live_session( + monkeypatch: pytest.MonkeyPatch, + status: str, +) -> None: + row = _session_row(id=41, status=status) + _patch_start( + monkeypatch, + projection=_projection(max_attempts=2), + state=ExamStartSessionState(attempt_count=0, existing_sessions=[row]), + ) + db = FakeSession() + response = await exams.start_exam_session(7, _request(), _user(), db) + assert response.session_id == 41 + assert db.added == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", ["submitted", "completed"]) +async def test_finished_status_is_not_resumed( + monkeypatch: pytest.MonkeyPatch, + status: str, +) -> None: + _patch_start( + monkeypatch, + projection=_projection(max_attempts=2), + state=ExamStartSessionState(attempt_count=1, existing_sessions=[]), + ) + + class CreatingSession(FakeSession): + def add(self, value: Any) -> None: + super().add(value) + if getattr(value, "exam_id", None) == 7: + value.id = 101 + value.start_time = _now() + value.violation_count = 0 + value.total_paused_seconds = 0 + + response = await exams.start_exam_session(7, _request(), _user(), CreatingSession()) + assert response.session_id == 101 + assert status in COMPLETED_ATTEMPT_STATUSES + assert status not in START_EXISTING_SESSION_STATUSES + + +@pytest.mark.asyncio +async def test_terminated_network_recovers_same_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + row = _session_row(id=41, status="terminated", terminated_by_admin=False) + _patch_start( + monkeypatch, + projection=_projection(max_attempts=2), + state=ExamStartSessionState(attempt_count=0, existing_sessions=[row]), + ) + db = FakeSession([FakeResult(rows=[])]) + response = await exams.start_exam_session(7, _request(), _user(), db) + assert response.session_id == 41 + assert row.status == "in_progress" + assert any( + getattr(item, "event_type", None) == "SESSION_AUTO_RESET_NETWORK" + for item in db.added + ) + + +@pytest.mark.asyncio +async def test_kicked_network_recovers_same_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + row = _session_row(id=42, status="kicked", terminated_by_admin=False) + _patch_start( + monkeypatch, + projection=_projection(max_attempts=2), + state=ExamStartSessionState(attempt_count=0, existing_sessions=[row]), + ) + db = FakeSession([FakeResult(rows=[])]) + response = await exams.start_exam_session(7, _request(), _user(), db) + assert response.session_id == 42 + + +@pytest.mark.asyncio +async def test_active_plus_prior_completed_resumes_when_under_max( + monkeypatch: pytest.MonkeyPatch, +) -> None: + row = _session_row(id=41, status="in_progress") + _patch_start( + monkeypatch, + projection=_projection(max_attempts=2), + state=ExamStartSessionState(attempt_count=1, existing_sessions=[row]), + ) + response = await exams.start_exam_session(7, _request(), _user(), FakeSession()) + assert response.session_id == 41 + + +@pytest.mark.asyncio +async def test_integrity_error_resumes_raced_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_start( + monkeypatch, + projection=_projection(max_attempts=1), + state=ExamStartSessionState(attempt_count=0, existing_sessions=[]), + ) + raced = SimpleNamespace( + id=77, + status="in_progress", + start_time=_now(), + end_time=None, + violation_count=0, + total_paused_seconds=0, + ) + db = FakeSession([FakeResult(rows=[raced])]) + db.flush_error = sqlalchemy.exc.IntegrityError("insert", {}, Exception("dup")) + response = await exams.start_exam_session(7, _request(), _user(), db) + assert response.session_id == 77 + assert db.rollbacks == 1 + + +@pytest.mark.asyncio +async def test_integrity_error_without_raced_session_returns_409( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_start( + monkeypatch, + projection=_projection(max_attempts=1), + state=ExamStartSessionState(attempt_count=0, existing_sessions=[]), + ) + db = FakeSession([FakeResult(rows=[])]) + db.flush_error = sqlalchemy.exc.IntegrityError("insert", {}, Exception("dup")) + with pytest.raises(HTTPException) as exc: + await exams.start_exam_session(7, _request(), _user(), db) + assert exc.value.status_code == 409 + assert db.rollbacks == 1 + + +def test_merged_query_is_scoped_per_user_and_exam() -> None: + sql = str( + build_exam_start_session_state_statement(5, 7).compile( + dialect=postgresql.dialect() + ) + ) + assert sql.count("user_id") >= 2 + assert sql.count("exam_id") >= 2 + + +def test_start_source_does_not_cache_session_state() -> None: + start_fn = __import__("pathlib").Path("app/api/exams.py").read_text(encoding="utf-8") + start_only = start_fn.split("async def start_exam_session")[1].split( + "async def ", + 1, + )[0] + assert "get_exam_start_session_state" in start_only + assert "cache_manager" not in start_only + helper = __import__("pathlib").Path("app/services/exam_service.py").read_text( + encoding="utf-8" + ) + fn = helper.split("async def get_exam_start_session_state")[1].split( + "async def ", + 1, + )[0] + assert "cache" not in fn.lower() + assert "for update" not in fn.lower() diff --git a/tests/test_exam_start_transaction_lifecycle.py b/tests/test_exam_start_transaction_lifecycle.py new file mode 100644 index 0000000..45411f1 --- /dev/null +++ b/tests/test_exam_start_transaction_lifecycle.py @@ -0,0 +1,388 @@ +from datetime import datetime, timedelta, timezone +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Optional + +import pytest +import sqlalchemy +from fastapi import HTTPException +from sqlalchemy import create_engine, event, text +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app.api import exams +from app.services.exam_service import ( + ExamStartCreatorView, + ExamStartProjection, + ExamStartSessionRow, + ExamStartSessionState, +) + + +class FakeResult: + def __init__(self, *, rows: list[Any] | None = None) -> None: + self.rows = rows or [] + + def scalars(self) -> "FakeResult": + return self + + def all(self) -> list[Any]: + return self.rows + + def scalar_one_or_none(self) -> Any: + return self.rows[0] if self.rows else None + + +class FakeSession: + def __init__(self, results: Optional[list[FakeResult]] = None) -> None: + self.results = iter(results or []) + self.added: list[Any] = [] + self.commits = 0 + self.rollbacks = 0 + self.flush_error: Optional[BaseException] = None + self.commit_error: Optional[BaseException] = None + self.log_error: Optional[BaseException] = None + + async def execute(self, _statement: Any) -> FakeResult: + try: + return next(self.results) + except StopIteration: + return FakeResult() + + def add(self, value: Any) -> None: + if self.log_error is not None and getattr(value, "event_type", None): + raise self.log_error + self.added.append(value) + if getattr(value, "user_id", None) == 5 and getattr(value, "id", None) is None: + value.id = 99 + if getattr(value, "start_time", None) is None: + value.start_time = datetime.now(timezone.utc) + value.violation_count = getattr(value, "violation_count", 0) or 0 + value.total_paused_seconds = getattr(value, "total_paused_seconds", 0) or 0 + + async def flush(self) -> None: + if self.flush_error is not None: + raise self.flush_error + + async def commit(self) -> None: + if self.commit_error is not None: + raise self.commit_error + self.commits += 1 + + async def rollback(self) -> None: + self.rollbacks += 1 + self.added.clear() + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _projection(**overrides: Any) -> ExamStartProjection: + now = _now() + values: dict[str, Any] = { + "id": 7, + "creator_id": 11, + "is_published": True, + "start_time": now - timedelta(minutes=5), + "end_time": now + timedelta(minutes=55), + "max_attempts": 2, + "allowed_classes": None, + "allowed_students": None, + "duration_minutes": 60, + "shuffle_questions": False, + "shuffle_options": False, + "title": "Ujian", + "subject": "MTK", + "exam_type": "UH", + "show_results": False, + "show_teacher_name": True, + "creator": ExamStartCreatorView(full_name="Guru", role="teacher"), + } + values.update(overrides) + return ExamStartProjection(**values) + + +def _session_row(**overrides: Any) -> ExamStartSessionRow: + values: dict[str, Any] = { + "id": 41, + "status": "in_progress", + "start_time": _now() - timedelta(minutes=3), + "end_time": None, + "terminated_by_admin": False, + "emergency_exit_allowed": False, + "violation_count": 0, + "total_paused_seconds": 0, + } + values.update(overrides) + return ExamStartSessionRow(**values) + + +def _request() -> Request: + return Request( + { + "type": "http", + "method": "POST", + "path": "/api/exams/7/start", + "headers": [], + "client": ("127.0.0.1", 1234), + "scheme": "http", + "server": ("testserver", 80), + } + ) + + +def _user() -> SimpleNamespace: + return SimpleNamespace(id=5, role="student", username="student", student_class="XII") + + +def _patch_start( + monkeypatch: pytest.MonkeyPatch, + *, + state: ExamStartSessionState, +) -> None: + class FakeExamService: + def __init__(self, _db: Any) -> None: + pass + + async def get_exam_start_projection(self, _exam_id: int) -> ExamStartProjection: + return _projection() + + async def get_exam_start_session_state( + self, + _user_id: int, + _exam_id: int, + ) -> ExamStartSessionState: + return state + + async def get_questions_payload(self, _exam_id: int) -> list[dict[str, Any]]: + return [ + { + "id": 1, + "question_text": "Q1", + "question_type": "multiple_choice", + "points": 1, + "order_index": 1, + "options": [{"id": 11, "option_text": "A", "order_index": 1}], + } + ] + + async def no_op(*_args: Any, **_kwargs: Any) -> None: + return None + + monkeypatch.setattr(exams, "ExamService", FakeExamService) + monkeypatch.setattr(exams, "validate_seb_headers", no_op) + monkeypatch.setattr(exams, "_ensure_exam_start_option_integrity", no_op) + monkeypatch.setattr(exams, "_ensure_exam_participant_access", lambda *_a, **_k: None) + monkeypatch.setattr( + exams, + "get_client_info", + lambda _request: { + "ip_address": "127.0.0.1", + "user_agent": "test", + "seb_detected": False, + }, + ) + monkeypatch.setattr(exams, "get_session_data", no_op) + monkeypatch.setattr(exams, "store_session_data", no_op) + monkeypatch.setattr(exams, "_publish_exam_monitor_event", no_op) + monkeypatch.setattr(exams, "create_session_poll_token", lambda **_k: "tok") + + +def test_empty_sqlalchemy_commit_is_logical_only() -> None: + engine = create_engine("sqlite://") + sql: list[str] = [] + events: list[str] = [] + + def _before(conn, cursor, statement, parameters, context, executemany) -> None: + sql.append(str(statement)) + + def _after_commit(_session) -> None: + events.append("commit") + + event.listen(engine, "before_cursor_execute", _before) + event.listen(Session, "after_commit", _after_commit) + try: + session = Session(engine, expire_on_commit=False, autoflush=False) + session.execute(text("SELECT 1")) + session.commit() + sql.clear() + events.clear() + session.commit() + session.close() + assert events == ["commit"] + assert sql == [] + finally: + event.remove(engine, "before_cursor_execute", _before) + event.remove(Session, "after_commit", _after_commit) + + +def test_start_has_single_explicit_commit() -> None: + source = Path("app/api/exams.py").read_text(encoding="utf-8") + fn = source.split("async def start_exam_session")[1].split("async def ", 1)[0] + assert fn.count("await db.commit()") == 1 + assert fn.count("await db.flush()") == 1 + assert fn.count("await db.rollback()") == 1 + assert "begin_nested" not in fn + + +def test_get_db_still_commits_on_success() -> None: + source = Path("app/database.py").read_text(encoding="utf-8") + fn = source.split("async def get_db()")[1].split("async def ", 1)[0] + assert "await session.commit()" in fn + + +@pytest.mark.asyncio +async def test_create_commits_once_before_redis( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_start( + monkeypatch, + state=ExamStartSessionState(attempt_count=0, existing_sessions=[]), + ) + db = FakeSession() + response = await exams.start_exam_session(7, _request(), _user(), db) + assert response.session_id == 99 + assert db.commits == 1 + assert db.rollbacks == 0 + assert any(getattr(item, "event_type", None) == "SESSION_START" for item in db.added) + + +@pytest.mark.asyncio +async def test_resume_commits_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_start( + monkeypatch, + state=ExamStartSessionState( + attempt_count=0, + existing_sessions=[_session_row()], + ), + ) + db = FakeSession() + response = await exams.start_exam_session(7, _request(), _user(), db) + assert response.session_id == 41 + assert db.commits == 1 + + +@pytest.mark.asyncio +async def test_recovery_commits_once_with_session_and_log( + monkeypatch: pytest.MonkeyPatch, +) -> None: + row = _session_row(status="terminated", terminated_by_admin=False) + _patch_start( + monkeypatch, + state=ExamStartSessionState(attempt_count=0, existing_sessions=[row]), + ) + db = FakeSession() + response = await exams.start_exam_session(7, _request(), _user(), db) + assert response.session_id == 41 + assert db.commits == 1 + assert any( + getattr(item, "event_type", None) == "SESSION_AUTO_RESET_NETWORK" + for item in db.added + ) + + +@pytest.mark.asyncio +async def test_session_insert_failure_leaves_no_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_start( + monkeypatch, + state=ExamStartSessionState(attempt_count=0, existing_sessions=[]), + ) + db = FakeSession() + db.flush_error = RuntimeError("insert failed") + with pytest.raises(RuntimeError, match="insert failed"): + await exams.start_exam_session(7, _request(), _user(), db) + assert db.commits == 0 + + +@pytest.mark.asyncio +async def test_examlog_insert_failure_does_not_commit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_start( + monkeypatch, + state=ExamStartSessionState(attempt_count=0, existing_sessions=[]), + ) + db = FakeSession() + db.log_error = RuntimeError("log failed") + with pytest.raises(RuntimeError, match="log failed"): + await exams.start_exam_session(7, _request(), _user(), db) + assert db.commits == 0 + + +@pytest.mark.asyncio +async def test_commit_failure_does_not_mark_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_start( + monkeypatch, + state=ExamStartSessionState(attempt_count=0, existing_sessions=[]), + ) + db = FakeSession() + db.commit_error = RuntimeError("commit failed") + with pytest.raises(RuntimeError, match="commit failed"): + await exams.start_exam_session(7, _request(), _user(), db) + assert db.commits == 0 + + +@pytest.mark.asyncio +async def test_integrity_error_race_still_resumes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_start( + monkeypatch, + state=ExamStartSessionState(attempt_count=0, existing_sessions=[]), + ) + raced = SimpleNamespace( + id=77, + status="in_progress", + start_time=_now(), + end_time=None, + violation_count=0, + total_paused_seconds=0, + ) + db = FakeSession([FakeResult(rows=[raced])]) + db.flush_error = sqlalchemy.exc.IntegrityError("insert", {}, Exception("dup")) + response = await exams.start_exam_session(7, _request(), _user(), db) + assert response.session_id == 77 + assert db.rollbacks == 1 + assert db.commits == 1 + + +@pytest.mark.asyncio +async def test_retry_after_failed_start_can_create( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_start( + monkeypatch, + state=ExamStartSessionState(attempt_count=0, existing_sessions=[]), + ) + failing = FakeSession() + failing.flush_error = RuntimeError("insert failed") + with pytest.raises(RuntimeError): + await exams.start_exam_session(7, _request(), _user(), failing) + retry = FakeSession() + response = await exams.start_exam_session(7, _request(), _user(), retry) + assert response.session_id == 99 + assert retry.commits == 1 + assert retry.rollbacks == 0 + + +@pytest.mark.asyncio +async def test_max_attempts_still_blocks_before_write( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_start( + monkeypatch, + state=ExamStartSessionState(attempt_count=2, existing_sessions=[]), + ) + db = FakeSession() + with pytest.raises(HTTPException) as exc: + await exams.start_exam_session(7, _request(), _user(), db) + assert exc.value.status_code == 400 + assert db.commits == 0 + assert db.added == [] diff --git a/tests/test_start_unified_admission.py b/tests/test_start_unified_admission.py new file mode 100644 index 0000000..33219d3 --- /dev/null +++ b/tests/test_start_unified_admission.py @@ -0,0 +1,342 @@ +import asyncio +from types import SimpleNamespace + +import pytest + +from app.core import cache +from app.core import start_db_admission as admission +from app.core.singleflight import KeyedSingleFlight +from app.core.start_db_admission import ( + _admission_limit, + _parse_admission_limit, + bind_start_admission, + configure_start_admission, + current_start_admission, + process_admission_snapshot, + reset_start_admission_for_tests, + start_db_segment, +) + + +@pytest.fixture(autouse=True) +def _reset_admission() -> None: + reset_start_admission_for_tests() + configure_start_admission(limit=6) + yield + reset_start_admission_for_tests() + + +async def _hold(segment: str, started: asyncio.Event, release: asyncio.Event) -> None: + async with start_db_segment(segment): + started.set() + await release.wait() + + +@pytest.mark.asyncio +async def test_mixed_segments_share_one_budget() -> None: + request = SimpleNamespace(state=SimpleNamespace()) + started = [asyncio.Event() for _ in range(30)] + release = asyncio.Event() + peaks: list[int] = [] + + async def runner(index: int, segment: str) -> None: + async with bind_start_admission(request): + await _hold(segment, started[index], release) + peaks.append(process_admission_snapshot()["peak_holders"]) + + segments = ( + ["main"] * 10 + + ["security"] * 10 + + ["questions"] * 8 + + ["integrity"] * 2 + ) + tasks = [ + asyncio.create_task(runner(index, segment)) + for index, segment in enumerate(segments) + ] + await asyncio.sleep(0.05) + snapshot = process_admission_snapshot() + assert snapshot["holders"] <= 6 + assert snapshot["peak_holders"] <= 6 + release.set() + await asyncio.gather(*tasks) + assert max(peaks) <= 6 + assert process_admission_snapshot()["holders"] == 0 + + +@pytest.mark.asyncio +async def test_nested_integrity_does_not_double_acquire() -> None: + request = SimpleNamespace(state=SimpleNamespace()) + async with bind_start_admission(request): + async with start_db_segment("main"): + assert process_admission_snapshot()["holders"] == 1 + async with start_db_segment("integrity"): + assert process_admission_snapshot()["holders"] == 1 + lease = current_start_admission() + assert lease is not None + assert lease.acquisitions[-1]["nested"] is True + assert lease.acquisitions[-1]["segment"] == "integrity" + assert process_admission_snapshot()["holders"] == 0 + + +@pytest.mark.asyncio +async def test_unbound_helpers_do_not_consume_start_permits() -> None: + release = asyncio.Event() + started = asyncio.Event() + counter = 0 + + async def unbound() -> None: + nonlocal counter + async with start_db_segment("security"): + counter += 1 + if counter == 12: + started.set() + await release.wait() + + tasks = [asyncio.create_task(unbound()) for _ in range(12)] + await asyncio.wait_for(started.wait(), timeout=1) + assert process_admission_snapshot()["holders"] == 0 + release.set() + await asyncio.gather(*tasks) + + +@pytest.mark.asyncio +async def test_singleflight_waiters_do_not_consume_permits() -> None: + request = SimpleNamespace(state=SimpleNamespace()) + flight = KeyedSingleFlight[str]() + acquire_count = 0 + loader_started = asyncio.Event() + release = asyncio.Event() + + async def loader() -> str: + nonlocal acquire_count + async with bind_start_admission(request): + async with start_db_segment("questions"): + acquire_count += 1 + loader_started.set() + await release.wait() + return "payload" + + async def caller() -> str: + async with bind_start_admission(request): + return await flight.run("exam:1", loader) + + tasks = [asyncio.create_task(caller()) for _ in range(20)] + await asyncio.wait_for(loader_started.wait(), timeout=1) + await asyncio.sleep(0.02) + assert acquire_count == 1 + assert process_admission_snapshot()["holders"] == 1 + release.set() + assert await asyncio.gather(*tasks) == ["payload"] * 20 + assert process_admission_snapshot()["holders"] == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("segment", ["security", "main", "questions", "integrity"]) +async def test_segment_exception_restores_permits(segment: str) -> None: + request = SimpleNamespace(state=SimpleNamespace()) + + async with bind_start_admission(request): + with pytest.raises(RuntimeError): + async with start_db_segment(segment): + raise RuntimeError("db failed") + assert process_admission_snapshot()["holders"] == 0 + assert process_admission_snapshot()["waiters"] == 0 + + async with bind_start_admission(request): + async with start_db_segment(segment): + assert process_admission_snapshot()["holders"] == 1 + assert process_admission_snapshot()["holders"] == 0 + + +@pytest.mark.asyncio +async def test_cancel_while_holding_restores_permit() -> None: + request = SimpleNamespace(state=SimpleNamespace()) + started = asyncio.Event() + + async def holder() -> None: + async with bind_start_admission(request): + async with start_db_segment("main"): + started.set() + await asyncio.sleep(60) + + task = asyncio.create_task(holder()) + await started.wait() + assert process_admission_snapshot()["holders"] == 1 + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert process_admission_snapshot()["holders"] == 0 + + +@pytest.mark.asyncio +async def test_cancel_while_waiting_does_not_leak_permit() -> None: + request = SimpleNamespace(state=SimpleNamespace()) + configure_start_admission(limit=1) + holders_started = asyncio.Event() + release_holder = asyncio.Event() + + async def holder() -> None: + async with bind_start_admission(request): + async with start_db_segment("main"): + holders_started.set() + await release_holder.wait() + + async def waiter() -> None: + async with bind_start_admission(request): + async with start_db_segment("security"): + return + + owner = asyncio.create_task(holder()) + await holders_started.wait() + waiting = asyncio.create_task(waiter()) + await asyncio.sleep(0.02) + assert process_admission_snapshot()["waiters"] == 1 + waiting.cancel() + with pytest.raises(asyncio.CancelledError): + await waiting + assert process_admission_snapshot()["holders"] == 1 + assert process_admission_snapshot()["waiters"] == 0 + release_holder.set() + await owner + assert process_admission_snapshot()["holders"] == 0 + + +@pytest.mark.asyncio +async def test_security_cache_fill_uses_gate_only_when_bound(monkeypatch) -> None: + request = SimpleNamespace(state=SimpleNamespace()) + fills = 0 + + class FakeSession: + async def execute(self, _statement): + nonlocal fills + fills += 1 + assert process_admission_snapshot()["holders"] == 1 + return SimpleNamespace(scalar_one_or_none=lambda: None) + + class FakeCtx: + async def __aenter__(self): + return FakeSession() + + async def __aexit__(self, *_args): + return None + + class FakeRedis: + async def get(self, _key): + return None + + async def set(self, *_args, **_kwargs): + return True + + async def fake_get_redis() -> FakeRedis: + return FakeRedis() + + monkeypatch.setattr(cache, "get_redis", fake_get_redis) + monkeypatch.setattr(cache, "async_session_read", lambda: FakeCtx()) + monkeypatch.setattr(cache, "_security_cache_fills", KeyedSingleFlight[str]()) + + async with bind_start_admission(request): + assert await cache.is_developer_mode_enabled() is False + assert fills == 1 + assert process_admission_snapshot()["holders"] == 0 + lease = request.state.start_db_admission + assert any(item["segment"] == "security" for item in lease["acquisitions"]) + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + (None, 0), + ("", 0), + (" ", 0), + ("0", 0), + ("4", 4), + ("not-a-number", 0), + ("-3", 0), + ("4.5", 0), + ], +) +def test_admission_limit_parsing_is_fail_safe(raw: str | None, expected: int) -> None: + assert _parse_admission_limit(raw) == expected + + +def test_missing_env_disables_admission(monkeypatch) -> None: + monkeypatch.delenv("START_DB_ADMISSION_LIMIT", raising=False) + reset_start_admission_for_tests() + assert _admission_limit() == 0 + + +def test_env_zero_disables_admission(monkeypatch) -> None: + monkeypatch.setenv("START_DB_ADMISSION_LIMIT", "0") + reset_start_admission_for_tests() + assert _admission_limit() == 0 + + +def test_env_four_enables_admission(monkeypatch) -> None: + monkeypatch.setenv("START_DB_ADMISSION_LIMIT", "4") + reset_start_admission_for_tests() + assert _admission_limit() == 4 + + +@pytest.mark.asyncio +async def test_disabled_gate_does_not_create_semaphore_or_holders() -> None: + reset_start_admission_for_tests() + configure_start_admission(limit=0) + request = SimpleNamespace(state=SimpleNamespace()) + started = [asyncio.Event() for _ in range(12)] + release = asyncio.Event() + + async def runner(index: int) -> None: + async with bind_start_admission(request): + async with start_db_segment("main"): + started[index].set() + await release.wait() + + tasks = [asyncio.create_task(runner(index)) for index in range(12)] + await asyncio.wait_for(asyncio.gather(*(event.wait() for event in started)), timeout=1) + snapshot = process_admission_snapshot() + assert snapshot["limit"] == 0 + assert snapshot["holders"] == 0 + assert admission._gate is not None + assert admission._gate.semaphore is None + release.set() + await asyncio.gather(*tasks) + assert process_admission_snapshot()["holders"] == 0 + + +@pytest.mark.asyncio +async def test_limit_four_caps_holders() -> None: + configure_start_admission(limit=4) + request = SimpleNamespace(state=SimpleNamespace()) + started = [asyncio.Event() for _ in range(8)] + release = asyncio.Event() + + async def runner(index: int) -> None: + async with bind_start_admission(request): + await _hold("main", started[index], release) + + tasks = [asyncio.create_task(runner(index)) for index in range(8)] + await asyncio.sleep(0.05) + snapshot = process_admission_snapshot() + assert snapshot["limit"] == 4 + assert snapshot["holders"] <= 4 + assert snapshot["peak_holders"] <= 4 + release.set() + await asyncio.gather(*tasks) + assert process_admission_snapshot()["holders"] == 0 + + +@pytest.mark.asyncio +async def test_gate_recreated_when_limit_changes() -> None: + configure_start_admission(limit=4) + request = SimpleNamespace(state=SimpleNamespace()) + async with bind_start_admission(request): + async with start_db_segment("main"): + first = process_admission_snapshot() + configure_start_admission(limit=2) + async with bind_start_admission(request): + async with start_db_segment("main"): + second = process_admission_snapshot() + assert first["limit"] == 4 + assert second["limit"] == 2 + assert second["pid"] == first["pid"] From 51511e9de8fe41c20cb55fd7384efaeeffb02c87 Mon Sep 17 00:00:00 2001 From: Fahmi Harun <34875577+kuker24@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:57:57 +0700 Subject: [PATCH 2/8] ops: add START admission telemetry --- app/api/metrics.py | 168 +++++++++++++++++++++- app/core/start_db_admission.py | 65 +++++++-- tests/test_start_unified_admission.py | 199 ++++++++++++++++++++++++++ 3 files changed, 423 insertions(+), 9 deletions(-) diff --git a/app/api/metrics.py b/app/api/metrics.py index 095e983..a166eff 100644 --- a/app/api/metrics.py +++ b/app/api/metrics.py @@ -2,10 +2,11 @@ Prometheus Metrics Endpoint Exposes application metrics for monitoring """ +import atexit import hmac import os -from fastapi import APIRouter, Response, Request, HTTPException +from fastapi import APIRouter, HTTPException, Request, Response from prometheus_client import ( CONTENT_TYPE_LATEST, CollectorRegistry, @@ -81,6 +82,171 @@ def _get_metrics_access_config() -> tuple[str, bool]: registry=registry ) +START_ADMISSION_HOLDERS = Gauge( + "siab_start_admission_holders", + "Current START admission permit holders in each worker", + ["replica"], + multiprocess_mode="liveall", + registry=registry, +) + +START_ADMISSION_LIMIT = Gauge( + "siab_start_admission_limit", + "Configured START admission limit in each worker", + ["replica"], + multiprocess_mode="liveall", + registry=registry, +) + +START_ADMISSION_WAITERS = Gauge( + "siab_start_admission_waiters", + "Current START admission waiters in each worker", + ["replica"], + multiprocess_mode="liveall", + registry=registry, +) + +START_ADMISSION_PEAK_HOLDERS = Gauge( + "siab_start_admission_peak_holders", + "Peak START admission permit holders since worker start", + ["replica"], + multiprocess_mode="liveall", + registry=registry, +) + +START_ADMISSION_PEAK_WAITERS = Gauge( + "siab_start_admission_peak_waiters", + "Peak START admission waiters since worker start", + ["replica"], + multiprocess_mode="liveall", + registry=registry, +) + +START_ADMISSION_WAIT = Histogram( + "siab_start_admission_wait_seconds", + "Time spent waiting for a START admission permit", + ["replica", "segment"], + buckets=( + 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, + 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, + ), + registry=registry, +) + +START_ADMISSION_ACQUISITIONS = Counter( + "siab_start_admission_acquisitions_total", + "Successful START admission permit acquisitions", + ["replica", "segment"], + registry=registry, +) + +START_ADMISSION_RELEASES = Counter( + "siab_start_admission_releases_total", + "Released START admission permits", + ["replica", "segment"], + registry=registry, +) + +START_ADMISSION_CANCELLATIONS = Counter( + "siab_start_admission_cancellations_total", + "START operations cancelled while waiting or holding a permit", + ["replica", "phase"], + registry=registry, +) + +START_ADMISSION_FAILURES = Counter( + "siab_start_admission_failures_total", + "START admission acquisition failures", + ["replica", "reason"], + registry=registry, +) + +START_DB_SECTION_DURATION = Histogram( + "siab_start_db_section_seconds", + "Duration of bounded START database sections", + ["replica", "segment"], + buckets=( + 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, + 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, + ), + registry=registry, +) + +_START_SEGMENTS = frozenset({"security", "main", "questions", "integrity"}) +_START_REPLICA = (os.getenv("SIAB_REPLICA") or "control").strip() or "control" + + +def _start_segment(segment: str) -> str: + return segment if segment in _START_SEGMENTS else "other" + + +def initialize_start_admission_metrics(limit: int) -> None: + labels = {"replica": _START_REPLICA} + START_ADMISSION_LIMIT.labels(**labels).set(limit) + START_ADMISSION_HOLDERS.labels(**labels).set(0) + START_ADMISSION_WAITERS.labels(**labels).set(0) + START_ADMISSION_PEAK_HOLDERS.labels(**labels).set(0) + START_ADMISSION_PEAK_WAITERS.labels(**labels).set(0) + + +def update_start_admission_metrics( + *, + holders: int, + waiters: int, + peak_holders: int, + peak_waiters: int, +) -> None: + labels = {"replica": _START_REPLICA} + START_ADMISSION_HOLDERS.labels(**labels).set(holders) + START_ADMISSION_WAITERS.labels(**labels).set(waiters) + START_ADMISSION_PEAK_HOLDERS.labels(**labels).set(peak_holders) + START_ADMISSION_PEAK_WAITERS.labels(**labels).set(peak_waiters) + + +def record_start_admission_acquisition(segment: str, wait_seconds: float) -> None: + labels = {"replica": _START_REPLICA, "segment": _start_segment(segment)} + START_ADMISSION_ACQUISITIONS.labels(**labels).inc() + START_ADMISSION_WAIT.labels(**labels).observe(max(0.0, wait_seconds)) + + +def record_start_admission_release(segment: str) -> None: + START_ADMISSION_RELEASES.labels( + replica=_START_REPLICA, + segment=_start_segment(segment), + ).inc() + + +def record_start_admission_cancellation(phase: str) -> None: + START_ADMISSION_CANCELLATIONS.labels( + replica=_START_REPLICA, + phase=phase if phase in {"waiting", "holding"} else "other", + ).inc() + + +def record_start_admission_failure(reason: str) -> None: + START_ADMISSION_FAILURES.labels( + replica=_START_REPLICA, + reason=reason if reason in {"timeout", "error"} else "other", + ).inc() + + +def record_start_db_section(segment: str, duration_seconds: float) -> None: + START_DB_SECTION_DURATION.labels( + replica=_START_REPLICA, + segment=_start_segment(segment), + ).observe(max(0.0, duration_seconds)) + + +def _mark_prometheus_process_dead() -> None: + try: + multiprocess.mark_process_dead(os.getpid()) + except OSError: + pass + + +if os.getenv("PROMETHEUS_MULTIPROC_DIR", "").strip(): + atexit.register(_mark_prometheus_process_dead) + @router.get("/metrics") async def metrics(request: Request): diff --git a/app/core/start_db_admission.py b/app/core/start_db_admission.py index b456a8d..8e89018 100644 --- a/app/core/start_db_admission.py +++ b/app/core/start_db_admission.py @@ -8,6 +8,16 @@ from contextvars import ContextVar, Token from typing import Any, AsyncIterator, Optional +from app.api.metrics import ( + initialize_start_admission_metrics, + record_start_admission_acquisition, + record_start_admission_cancellation, + record_start_admission_failure, + record_start_admission_release, + record_start_db_section, + update_start_admission_metrics, +) + START_PATH_RE = re.compile(r"^/api/exams/\d+/start$") _DEFAULT_LIMIT = 0 @@ -55,6 +65,15 @@ def __init__(self, limit: int) -> None: self.waiters = 0 self.peak_holders = 0 self.peak_waiters = 0 + initialize_start_admission_metrics(limit) + + def publish_metrics(self) -> None: + update_start_admission_metrics( + holders=self.holders, + waiters=self.waiters, + peak_holders=self.peak_holders, + peak_waiters=self.peak_waiters, + ) def _get_gate(limit: int) -> ProcessAdmissionGate: @@ -157,14 +176,21 @@ async def acquire(self, segment: str) -> AsyncIterator[dict[str, Any]]: self.publish() try: yield record + except asyncio.CancelledError: + record_start_admission_cancellation("holding") + raise finally: - record["hold_ms"] = (time.monotonic() - started) * 1000.0 + hold_seconds = time.monotonic() - started + record["hold_ms"] = hold_seconds * 1000.0 record["released_wall"] = time.time() self.depth -= 1 + record_start_db_section(segment, hold_seconds) self.publish() return - if self.gate.limit <= 0 or self.gate.semaphore is None: + gate = self.gate + semaphore = gate.semaphore + if gate.limit <= 0 or semaphore is None: started = time.monotonic() started_wall = time.time() record = { @@ -182,27 +208,43 @@ async def acquire(self, segment: str) -> AsyncIterator[dict[str, Any]]: self.publish() try: yield record + except asyncio.CancelledError: + record_start_admission_cancellation("holding") + raise finally: - record["hold_ms"] = (time.monotonic() - started) * 1000.0 + hold_seconds = time.monotonic() - started + record["hold_ms"] = hold_seconds * 1000.0 record["released_wall"] = time.time() self.depth = 0 + record_start_db_section(segment, hold_seconds) self.publish() return - gate = self.gate gate.waiters += 1 gate.peak_waiters = max(gate.peak_waiters, gate.waiters) + gate.publish_metrics() wait_started = time.monotonic() try: - await gate.semaphore.acquire() - except BaseException: + await semaphore.acquire() + except asyncio.CancelledError: gate.waiters = max(0, gate.waiters - 1) + gate.publish_metrics() + record_start_admission_cancellation("waiting") + self.publish() + raise + except BaseException as exc: + gate.waiters = max(0, gate.waiters - 1) + gate.publish_metrics() + reason = "timeout" if isinstance(exc, TimeoutError) else "error" + record_start_admission_failure(reason) self.publish() raise acquired = time.monotonic() gate.waiters = max(0, gate.waiters - 1) gate.holders += 1 gate.peak_holders = max(gate.peak_holders, gate.holders) + gate.publish_metrics() + record_start_admission_acquisition(segment, acquired - wait_started) record = { "segment": segment, "nested": False, @@ -220,14 +262,21 @@ async def acquire(self, segment: str) -> AsyncIterator[dict[str, Any]]: self.publish() try: yield record + except asyncio.CancelledError: + record_start_admission_cancellation("holding") + raise finally: - record["hold_ms"] = (time.monotonic() - acquired) * 1000.0 + hold_seconds = time.monotonic() - acquired + record["hold_ms"] = hold_seconds * 1000.0 record["released_wall"] = time.time() record["peak_holders"] = gate.peak_holders record["peak_waiters"] = gate.peak_waiters self.depth = 0 gate.holders = max(0, gate.holders - 1) - gate.semaphore.release() + semaphore.release() + gate.publish_metrics() + record_start_admission_release(segment) + record_start_db_section(segment, hold_seconds) self.publish() diff --git a/tests/test_start_unified_admission.py b/tests/test_start_unified_admission.py index 33219d3..0975449 100644 --- a/tests/test_start_unified_admission.py +++ b/tests/test_start_unified_admission.py @@ -3,6 +3,7 @@ import pytest +from app.api import metrics as start_metrics from app.core import cache from app.core import start_db_admission as admission from app.core.singleflight import KeyedSingleFlight @@ -32,6 +33,16 @@ async def _hold(segment: str, started: asyncio.Event, release: asyncio.Event) -> await release.wait() +def _metric_value(collector, sample_name: str, labels: dict[str, str]) -> float: + for metric in collector.collect(): + for sample in metric.samples: + if sample.name != sample_name: + continue + if all(sample.labels.get(key) == value for key, value in labels.items()): + return float(sample.value) + return 0.0 + + @pytest.mark.asyncio async def test_mixed_segments_share_one_budget() -> None: request = SimpleNamespace(state=SimpleNamespace()) @@ -153,6 +164,12 @@ async def test_segment_exception_restores_permits(segment: str) -> None: async def test_cancel_while_holding_restores_permit() -> None: request = SimpleNamespace(state=SimpleNamespace()) started = asyncio.Event() + labels = {"replica": start_metrics._START_REPLICA, "phase": "holding"} + cancellations_before = _metric_value( + start_metrics.START_ADMISSION_CANCELLATIONS, + "siab_start_admission_cancellations_total", + labels, + ) async def holder() -> None: async with bind_start_admission(request): @@ -167,6 +184,11 @@ async def holder() -> None: with pytest.raises(asyncio.CancelledError): await task assert process_admission_snapshot()["holders"] == 0 + assert _metric_value( + start_metrics.START_ADMISSION_CANCELLATIONS, + "siab_start_admission_cancellations_total", + labels, + ) - cancellations_before == 1 @pytest.mark.asyncio @@ -202,6 +224,183 @@ async def waiter() -> None: assert process_admission_snapshot()["holders"] == 0 +@pytest.mark.asyncio +async def test_limit_four_exports_worker_metrics_and_restores_permits() -> None: + configure_start_admission(limit=4) + request = SimpleNamespace(state=SimpleNamespace()) + release = asyncio.Event() + started = [asyncio.Event() for _ in range(8)] + labels = {"replica": start_metrics._START_REPLICA} + acquisitions_before = _metric_value( + start_metrics.START_ADMISSION_ACQUISITIONS, + "siab_start_admission_acquisitions_total", + {**labels, "segment": "main"}, + ) + releases_before = _metric_value( + start_metrics.START_ADMISSION_RELEASES, + "siab_start_admission_releases_total", + {**labels, "segment": "main"}, + ) + + async def runner(index: int) -> None: + async with bind_start_admission(request): + await _hold("main", started[index], release) + + tasks = [asyncio.create_task(runner(index)) for index in range(8)] + await asyncio.sleep(0.05) + snapshot = process_admission_snapshot() + assert snapshot["holders"] == 4 + assert snapshot["waiters"] == 4 + assert _metric_value( + start_metrics.START_ADMISSION_LIMIT, + "siab_start_admission_limit", + labels, + ) == 4 + assert _metric_value( + start_metrics.START_ADMISSION_HOLDERS, + "siab_start_admission_holders", + labels, + ) == 4 + assert _metric_value( + start_metrics.START_ADMISSION_WAITERS, + "siab_start_admission_waiters", + labels, + ) == 4 + assert _metric_value( + start_metrics.START_ADMISSION_PEAK_HOLDERS, + "siab_start_admission_peak_holders", + labels, + ) == 4 + assert _metric_value( + start_metrics.START_ADMISSION_PEAK_WAITERS, + "siab_start_admission_peak_waiters", + labels, + ) == 4 + + release.set() + await asyncio.gather(*tasks) + assert process_admission_snapshot()["holders"] == 0 + assert process_admission_snapshot()["waiters"] == 0 + assert _metric_value( + start_metrics.START_ADMISSION_HOLDERS, + "siab_start_admission_holders", + labels, + ) == 0 + assert _metric_value( + start_metrics.START_ADMISSION_WAITERS, + "siab_start_admission_waiters", + labels, + ) == 0 + assert _metric_value( + start_metrics.START_ADMISSION_ACQUISITIONS, + "siab_start_admission_acquisitions_total", + {**labels, "segment": "main"}, + ) - acquisitions_before == 8 + assert _metric_value( + start_metrics.START_ADMISSION_RELEASES, + "siab_start_admission_releases_total", + {**labels, "segment": "main"}, + ) - releases_before == 8 + + +@pytest.mark.asyncio +async def test_admission_metrics_record_wait_hold_and_cancellation() -> None: + configure_start_admission(limit=1) + request = SimpleNamespace(state=SimpleNamespace()) + holder_started = asyncio.Event() + release_holder = asyncio.Event() + labels = {"replica": start_metrics._START_REPLICA} + wait_count_before = _metric_value( + start_metrics.START_ADMISSION_WAIT, + "siab_start_admission_wait_seconds_count", + {**labels, "segment": "main"}, + ) + db_count_before = _metric_value( + start_metrics.START_DB_SECTION_DURATION, + "siab_start_db_section_seconds_count", + {**labels, "segment": "main"}, + ) + cancellations_before = _metric_value( + start_metrics.START_ADMISSION_CANCELLATIONS, + "siab_start_admission_cancellations_total", + {**labels, "phase": "waiting"}, + ) + + async def holder() -> None: + async with bind_start_admission(request): + async with start_db_segment("main"): + holder_started.set() + await release_holder.wait() + + async def waiter() -> None: + async with bind_start_admission(request): + async with start_db_segment("main"): + return + + owner = asyncio.create_task(holder()) + await holder_started.wait() + waiting = asyncio.create_task(waiter()) + await asyncio.sleep(0.02) + waiting.cancel() + with pytest.raises(asyncio.CancelledError): + await waiting + release_holder.set() + await owner + + assert _metric_value( + start_metrics.START_ADMISSION_WAIT, + "siab_start_admission_wait_seconds_count", + {**labels, "segment": "main"}, + ) - wait_count_before == 1 + assert _metric_value( + start_metrics.START_DB_SECTION_DURATION, + "siab_start_db_section_seconds_count", + {**labels, "segment": "main"}, + ) - db_count_before == 1 + assert _metric_value( + start_metrics.START_ADMISSION_CANCELLATIONS, + "siab_start_admission_cancellations_total", + {**labels, "phase": "waiting"}, + ) - cancellations_before == 1 + + +@pytest.mark.asyncio +async def test_disabled_gate_exports_zero_gauges_without_permit_metrics() -> None: + configure_start_admission(limit=0) + request = SimpleNamespace(state=SimpleNamespace()) + labels = {"replica": start_metrics._START_REPLICA} + acquisitions_before = _metric_value( + start_metrics.START_ADMISSION_ACQUISITIONS, + "siab_start_admission_acquisitions_total", + {**labels, "segment": "main"}, + ) + + async with bind_start_admission(request): + async with start_db_segment("main"): + pass + + assert _metric_value( + start_metrics.START_ADMISSION_LIMIT, + "siab_start_admission_limit", + labels, + ) == 0 + assert _metric_value( + start_metrics.START_ADMISSION_HOLDERS, + "siab_start_admission_holders", + labels, + ) == 0 + assert _metric_value( + start_metrics.START_ADMISSION_WAITERS, + "siab_start_admission_waiters", + labels, + ) == 0 + assert _metric_value( + start_metrics.START_ADMISSION_ACQUISITIONS, + "siab_start_admission_acquisitions_total", + {**labels, "segment": "main"}, + ) == acquisitions_before + + @pytest.mark.asyncio async def test_security_cache_fill_uses_gate_only_when_bound(monkeypatch) -> None: request = SimpleNamespace(state=SimpleNamespace()) From 3ad01e9ab0ebdba64d835a5ddcfbad00e30a4bd3 Mon Sep 17 00:00:00 2001 From: SIAB1 Operations Date: Thu, 27 Aug 2026 01:39:01 +0700 Subject: [PATCH 3/8] docs: archive production migration provenance without credentials Record the live control SQL artifacts as inert evidence, keep the sanitized developer migration, and add a read-only schema fingerprint. --- .../migration-history/CONTROL_MANIFEST.json | 76 ++++++++++ docs/operations/migration-history/README.md | 70 +++++++++ ...artition_exam_logs_and_hot_indexes.sql.txt | 134 ++++++++++++++++++ ...13_exam_logs_partition_maintenance.sql.txt | 37 +++++ .../20260418_users_role_guruplus.sql.txt | 9 ++ .../archive/create_materialized_views.sql.txt | 41 ++++++ scripts/schema_fingerprint_readonly.py | 130 +++++++++++++++++ tests/test_migration_provenance.py | 79 +++++++++++ 8 files changed, 576 insertions(+) create mode 100644 docs/operations/migration-history/CONTROL_MANIFEST.json create mode 100644 docs/operations/migration-history/README.md create mode 100644 docs/operations/migration-history/archive/20260312_partition_exam_logs_and_hot_indexes.sql.txt create mode 100644 docs/operations/migration-history/archive/20260313_exam_logs_partition_maintenance.sql.txt create mode 100644 docs/operations/migration-history/archive/20260418_users_role_guruplus.sql.txt create mode 100644 docs/operations/migration-history/archive/create_materialized_views.sql.txt create mode 100644 scripts/schema_fingerprint_readonly.py create mode 100644 tests/test_migration_provenance.py diff --git a/docs/operations/migration-history/CONTROL_MANIFEST.json b/docs/operations/migration-history/CONTROL_MANIFEST.json new file mode 100644 index 0000000..034a1f8 --- /dev/null +++ b/docs/operations/migration-history/CONTROL_MANIFEST.json @@ -0,0 +1,76 @@ +{ + "control_id": "siab1-control-20260827-app-3f8fc938a226-mig-08519407c207", + "captured_at": "2026-08-27T00:00:00Z", + "base_git_commit": "51511e9de8fe41c20cb55fd7384efaeeffb02c87", + "application": { + "control": "live-control-20260826-3f8fc938a226", + "tree_sha256": "3f8fc938a226dbb479461842539f5e83c1e4c77cbb781c2cd4050520be0d4640", + "file_count": 161, + "snapshot_path": "/opt/siab1-stable-app", + "snapshot_archive_sha256": "cc6daeecbb347b5c1ba629edb7d0510cee4432544a1eb14379d8f3b4bf3f9839" + }, + "migrations": { + "algorithm": "sha256(sorted path\\tfile-sha256\\n)", + "set_sha256": "08519407c20727210f665d8b25556ba93eb5de2d4a549216b088b2049855d241", + "ledger": "NONE", + "canonical_files": [ + { + "path": "app/migrations/20260423_developer_role_and_seed_accounts.sql", + "sha256": "60015d94a9017c6f50e0cf8e67a5efc9f2079ffeffcc928855c48d497913c0f7", + "role": "sanitized-replacement", + "executable": false + }, + { + "path": "docs/operations/migration-history/archive/20260312_partition_exam_logs_and_hot_indexes.sql.txt", + "sha256": "abb39dddd7dd3bd258c9b5352a62c9acf5b83d887fa90e49f3dfd118f580942f", + "role": "unapplied-historical-evidence", + "executable": false + }, + { + "path": "docs/operations/migration-history/archive/20260313_exam_logs_partition_maintenance.sql.txt", + "sha256": "bcbbaafd4f7418478784e6e08581664999e6b4c202d8f113b4ee1a20a9761ec1", + "role": "unapplied-historical-evidence", + "executable": false + }, + { + "path": "docs/operations/migration-history/archive/20260418_users_role_guruplus.sql.txt", + "sha256": "2eb9307fcde1368ded7b7da0c0f0c87669098bf8f5d2e8915bbdb639a7af04c0", + "role": "ambiguous-historical-evidence", + "executable": false + }, + { + "path": "docs/operations/migration-history/archive/create_materialized_views.sql.txt", + "sha256": "52c436fac5e46c615eb4f814d6082e91ca81698c49cddc9556566b5f4f1dd1b6", + "role": "unapplied-historical-evidence", + "executable": false + } + ], + "intentionally_omitted": [ + { + "production_path": "app/migrations/20260423_developer_role_and_seed_accounts.sql", + "production_sha256": "b7ae71a0d0900b2e991f828910e32d71f5b5441c7436371da9c03288aaef7957", + "reason": "credential-bearing production body is not imported" + } + ] + }, + "schema": { + "algorithm": "sha256(canonical-json(public-schema-v1))", + "fingerprint_sha256": "fe17317063614ce1875015bc8d1a2cc744904ab0269bcc24ab9bc3e7fe59961f", + "record_count": 380, + "counts": { + "relation": 41, + "column": 240, + "index": 38, + "constraint": 61, + "view": 0, + "function": 0, + "trigger": 0 + } + }, + "strategy": "C", + "notes": [ + "No authoritative migration ledger exists.", + "Archived SQL files are inert evidence and must not be executed.", + "Current schema is explained by docker/init.sql, SQLAlchemy models, and init_db compatibility, not by replaying the archived files." + ] +} diff --git a/docs/operations/migration-history/README.md b/docs/operations/migration-history/README.md new file mode 100644 index 0000000..cfae8c0 --- /dev/null +++ b/docs/operations/migration-history/README.md @@ -0,0 +1,70 @@ +# Production Migration Provenance + +This directory records SQL artifacts found in the production application tree for +`live-control-20260826-3f8fc938a226`. + +## Safety Contract + +- Files under `archive/` are inert evidence, not executable migrations. +- The archived files use the `.sql.txt` suffix and live outside `app/migrations` so + migration discovery cannot execute them. +- Three archived scripts have no matching effect in the current production schema. +- Committing an archived file never means that it should be applied. +- SIAB1 has no authoritative migration ledger. Filename order is not application order. +- Never execute an archive file against a database without a separately approved change. + +## Canonical Status + +| Production file | Provenance | Production DB evidence | Canonical treatment | +|---|---|---|---| +| `20260312_partition_exam_logs_and_hot_indexes.sql` | Never tracked in canonical or legacy Git objects | Absent; `exam_logs` remains a heap and all named indexes are absent | Archive as unapplied evidence | +| `20260313_exam_logs_partition_maintenance.sql` | Never tracked in canonical or legacy Git objects | Function and generated partitions are absent | Archive as unapplied evidence | +| `20260418_users_role_guruplus.sql` | Never tracked in canonical or legacy Git objects | Constraint effect is present, but the same effect is enforced by `app/database.py` | Archive as ambiguous historical evidence | +| `20260423_developer_role_and_seed_accounts.sql` | Production body was never tracked; sanitized replacement entered canonical Git at `0af42f40bf2224870bda38d402f6985ac4706148` | Referenced accounts are absent and no account uses the embedded hash | Do not archive the production body; keep the sanitized replacement | +| `create_materialized_views.sql` | Exact blob introduced in legacy commit `c40b2469792b6cbb395b7d307cac700ada8bc6f5` and copied into sanitized-root commit `2d853cbfd6a390c33d42ed2a9281ae2d8afec429` | Materialized views and their indexes are absent | Archive as unapplied evidence | + +## Credential Hygiene + +The production-only body of `20260423_developer_role_and_seed_accounts.sql` +contains one hardcoded bcrypt password hash reused by update/upsert statements. It +does not contain a plaintext password. The hash occurred in legacy Git history in +`docker/init.sql`, but does not occur in the canonical SIAB1 Git object database. + +Read-only production verification found: + +- no referenced migration account still present; +- no production account using the embedded hash; +- no active production account using the embedded hash. + +The production body is intentionally not copied into this history. Its sanitized +replacement is `app/migrations/20260423_developer_role_and_seed_accounts.sql`. + +## Runner Semantics + +The repository has no Alembic configuration, SQL glob runner, or migration ledger. +`scripts/init_materialized_views.py` is an explicit manual command, while +`app/tasks/views_refresher.py` owns the current optional materialized-view DDL. +`app/database.py` owns the current users-role compatibility constraint. + +The archive therefore documents provenance without expanding any executable +migration path. + +## Control Identity + +- Control ID: `siab1-control-20260827-app-3f8fc938a226-mig-08519407c207` +- Application tree: `3f8fc938a226dbb479461842539f5e83c1e4c77cbb781c2cd4050520be0d4640` +- Canonical migration set: `08519407c20727210f665d8b25556ba93eb5de2d4a549216b088b2049855d241` +- Schema fingerprint: `fe17317063614ce1875015bc8d1a2cc744904ab0269bcc24ab9bc3e7fe59961f` + +Machine-readable copy: `CONTROL_MANIFEST.json`. + +## Verification + +Run the deterministic read-only schema fingerprint from an application environment: + +```bash +python scripts/schema_fingerprint_readonly.py +``` + +The command opens a read-only transaction and prints only object counts and a SHA-256 +fingerprint. It excludes table rows, credential hashes, PII, and unstable timestamps. diff --git a/docs/operations/migration-history/archive/20260312_partition_exam_logs_and_hot_indexes.sql.txt b/docs/operations/migration-history/archive/20260312_partition_exam_logs_and_hot_indexes.sql.txt new file mode 100644 index 0000000..7fd286e --- /dev/null +++ b/docs/operations/migration-history/archive/20260312_partition_exam_logs_and_hot_indexes.sql.txt @@ -0,0 +1,134 @@ +-- Phase 3 (safe-fast): partition exam_logs + add hot-path indexes +-- Date: 2026-03-12 +-- +-- Scope: +-- 1) Convert exam_logs heap table to RANGE partitioned by created_at. +-- 2) Keep legacy copy (exam_logs_legacy_20260312) for rollback. +-- 3) Add practical indexes on answers/exam_sessions for heavy monitor queries. +-- +-- Notes: +-- - This script is designed for PostgreSQL 15. +-- - Run during low traffic window (no active exams recommended). +-- - Idempotent for index creation; partition conversion is guarded and one-time. + +DO $$ +DECLARE + is_partitioned boolean; +BEGIN + SELECT EXISTS ( + SELECT 1 + FROM pg_partitioned_table pt + JOIN pg_class c ON c.oid = pt.partrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND c.relname = 'exam_logs' + ) INTO is_partitioned; + + IF is_partitioned THEN + RAISE NOTICE 'exam_logs is already partitioned. Skipping conversion block.'; + RETURN; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'exam_logs_legacy_20260312' + ) THEN + RAISE EXCEPTION 'Guard stop: exam_logs_legacy_20260312 already exists, manual review required.'; + END IF; +END +$$; + +-- Convert exam_logs only when still heap table. +DO $$ +DECLARE + is_heap boolean; +BEGIN + SELECT EXISTS ( + SELECT 1 + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND c.relname = 'exam_logs' + AND c.relkind = 'r' + ) INTO is_heap; + + IF NOT is_heap THEN + RAISE NOTICE 'exam_logs is not a heap table anymore. Skip conversion.'; + RETURN; + END IF; + + EXECUTE 'LOCK TABLE public.exam_logs IN ACCESS EXCLUSIVE MODE'; + EXECUTE 'ALTER TABLE public.exam_logs RENAME TO exam_logs_legacy_20260312'; + + EXECUTE $ddl$ + CREATE TABLE public.exam_logs ( + id integer NOT NULL DEFAULT nextval('exam_logs_id_seq'::regclass), + session_id integer NOT NULL, + event_type character varying(50) NOT NULL, + event_data jsonb, + created_at timestamp with time zone NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'::text), + CONSTRAINT exam_logs_partitioned_pkey PRIMARY KEY (id, created_at), + CONSTRAINT exam_logs_partitioned_session_id_fkey + FOREIGN KEY (session_id) REFERENCES public.exam_sessions(id) ON DELETE CASCADE + ) PARTITION BY RANGE (created_at) + $ddl$; + + EXECUTE 'ALTER SEQUENCE public.exam_logs_id_seq OWNED BY public.exam_logs.id'; + + -- Time partitions (active + near future) + default catch-all. + EXECUTE 'CREATE TABLE public.exam_logs_2026_01 PARTITION OF public.exam_logs FOR VALUES FROM (''2026-01-01'') TO (''2026-02-01'')'; + EXECUTE 'CREATE TABLE public.exam_logs_2026_02 PARTITION OF public.exam_logs FOR VALUES FROM (''2026-02-01'') TO (''2026-03-01'')'; + EXECUTE 'CREATE TABLE public.exam_logs_2026_03 PARTITION OF public.exam_logs FOR VALUES FROM (''2026-03-01'') TO (''2026-04-01'')'; + EXECUTE 'CREATE TABLE public.exam_logs_2026_04 PARTITION OF public.exam_logs FOR VALUES FROM (''2026-04-01'') TO (''2026-05-01'')'; + EXECUTE 'CREATE TABLE public.exam_logs_2026_05 PARTITION OF public.exam_logs FOR VALUES FROM (''2026-05-01'') TO (''2026-06-01'')'; + EXECUTE 'CREATE TABLE public.exam_logs_2026_06 PARTITION OF public.exam_logs FOR VALUES FROM (''2026-06-01'') TO (''2026-07-01'')'; + EXECUTE 'CREATE TABLE public.exam_logs_2026_07 PARTITION OF public.exam_logs FOR VALUES FROM (''2026-07-01'') TO (''2026-08-01'')'; + EXECUTE 'CREATE TABLE public.exam_logs_2026_08 PARTITION OF public.exam_logs FOR VALUES FROM (''2026-08-01'') TO (''2026-09-01'')'; + EXECUTE 'CREATE TABLE public.exam_logs_2026_09 PARTITION OF public.exam_logs FOR VALUES FROM (''2026-09-01'') TO (''2026-10-01'')'; + EXECUTE 'CREATE TABLE public.exam_logs_2026_10 PARTITION OF public.exam_logs FOR VALUES FROM (''2026-10-01'') TO (''2026-11-01'')'; + EXECUTE 'CREATE TABLE public.exam_logs_2026_11 PARTITION OF public.exam_logs FOR VALUES FROM (''2026-11-01'') TO (''2026-12-01'')'; + EXECUTE 'CREATE TABLE public.exam_logs_2026_12 PARTITION OF public.exam_logs FOR VALUES FROM (''2026-12-01'') TO (''2027-01-01'')'; + EXECUTE 'CREATE TABLE public.exam_logs_2027_01 PARTITION OF public.exam_logs FOR VALUES FROM (''2027-01-01'') TO (''2027-02-01'')'; + EXECUTE 'CREATE TABLE public.exam_logs_default PARTITION OF public.exam_logs DEFAULT'; + + -- Parent indexes propagate to partitions in PG15. + EXECUTE 'CREATE INDEX idx_exam_logs_created_at ON public.exam_logs (created_at DESC)'; + EXECUTE 'CREATE INDEX idx_exam_logs_session_created_at ON public.exam_logs (session_id, created_at DESC)'; + EXECUTE 'CREATE INDEX idx_exam_logs_event_type_created_at ON public.exam_logs (event_type, created_at DESC)'; + EXECUTE 'CREATE INDEX idx_exam_logs_id ON public.exam_logs (id DESC)'; + + -- Migrate historical rows from legacy heap table. + EXECUTE $copy$ + INSERT INTO public.exam_logs (id, session_id, event_type, event_data, created_at) + SELECT id, session_id, event_type, event_data, created_at + FROM public.exam_logs_legacy_20260312 + ORDER BY id + $copy$; + + EXECUTE $seq$ + SELECT setval( + 'public.exam_logs_id_seq', + COALESCE((SELECT MAX(id) FROM public.exam_logs), 1), + true + ) + $seq$; +END +$$; + +-- Hot-path indexes for heavy read/query patterns. +CREATE INDEX IF NOT EXISTS idx_answers_session_answered_at + ON public.answers (session_id, answered_at DESC); + +CREATE INDEX IF NOT EXISTS idx_answers_question_session + ON public.answers (question_id, session_id); + +CREATE INDEX IF NOT EXISTS idx_exam_sessions_exam_start + ON public.exam_sessions (exam_id, start_time DESC); + +CREATE INDEX IF NOT EXISTS idx_exam_sessions_status_exam_start + ON public.exam_sessions (status, exam_id, start_time DESC); + +CREATE INDEX IF NOT EXISTS idx_exam_sessions_end_time + ON public.exam_sessions (end_time DESC); diff --git a/docs/operations/migration-history/archive/20260313_exam_logs_partition_maintenance.sql.txt b/docs/operations/migration-history/archive/20260313_exam_logs_partition_maintenance.sql.txt new file mode 100644 index 0000000..7e4a182 --- /dev/null +++ b/docs/operations/migration-history/archive/20260313_exam_logs_partition_maintenance.sql.txt @@ -0,0 +1,37 @@ +-- Ensure future monthly partitions for exam_logs are always available. +-- Run manually after deployment or via monthly maintenance job. + +CREATE OR REPLACE FUNCTION public.ensure_exam_logs_monthly_partitions( + p_start_date date DEFAULT CURRENT_DATE, + p_months_ahead integer DEFAULT 12 +) +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + v_i integer; + v_month_start date; + v_month_end date; + v_partition_name text; +BEGIN + IF p_months_ahead < 0 THEN + RAISE EXCEPTION 'p_months_ahead must be >= 0'; + END IF; + + FOR v_i IN 0..p_months_ahead LOOP + v_month_start := (date_trunc('month', p_start_date)::date + (v_i || ' month')::interval)::date; + v_month_end := (v_month_start + interval '1 month')::date; + v_partition_name := format('exam_logs_%s', to_char(v_month_start, 'YYYY_MM')); + + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS public.%I PARTITION OF public.exam_logs FOR VALUES FROM (%L) TO (%L)', + v_partition_name, + v_month_start, + v_month_end + ); + END LOOP; +END; +$$; + +-- Pre-create partitions for the next 18 months. +SELECT public.ensure_exam_logs_monthly_partitions(CURRENT_DATE, 18); diff --git a/docs/operations/migration-history/archive/20260418_users_role_guruplus.sql.txt b/docs/operations/migration-history/archive/20260418_users_role_guruplus.sql.txt new file mode 100644 index 0000000..37e9e12 --- /dev/null +++ b/docs/operations/migration-history/archive/20260418_users_role_guruplus.sql.txt @@ -0,0 +1,9 @@ +-- Add GuruPlus role support to users.role check constraint. +-- Safe to run repeatedly. + +ALTER TABLE users +DROP CONSTRAINT IF EXISTS users_role_check; + +ALTER TABLE users +ADD CONSTRAINT users_role_check +CHECK (role IN ('developer', 'admin', 'teacher', 'student', 'guruplus')); diff --git a/docs/operations/migration-history/archive/create_materialized_views.sql.txt b/docs/operations/migration-history/archive/create_materialized_views.sql.txt new file mode 100644 index 0000000..ae31f1c --- /dev/null +++ b/docs/operations/migration-history/archive/create_materialized_views.sql.txt @@ -0,0 +1,41 @@ +-- 1. Exam Results Summary +CREATE MATERIALIZED VIEW IF NOT EXISTS exam_results_summary AS +SELECT + e.id as exam_id, + e.title as exam_title, + e.subject, + e.creator_id, + COUNT(es.id) as total_sessions, + COUNT(DISTINCT es.user_id) as total_participants, + ROUND(AVG(es.score), 2) as avg_score, + MAX(es.score) as highest_score, + MIN(es.score) as lowest_score, + COUNT(CASE WHEN es.score >= COALESCE(e.passing_score, 0) THEN 1 END) as passed_count, + NOW() as last_updated +FROM exams e +JOIN exam_sessions es ON e.id = es.exam_id +WHERE es.status IN ('completed', 'submitted') +GROUP BY e.id, e.title, e.subject, e.creator_id; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_exam_results_summary_id ON exam_results_summary (exam_id); + +-- 2. Class Exam Performance +CREATE MATERIALIZED VIEW IF NOT EXISTS class_exam_performance AS +SELECT + u.student_class as class_name, + e.id as exam_id, + e.title as exam_title, + COUNT(DISTINCT es.user_id) as total_students, + ROUND(AVG(es.score), 2) as avg_score, + MAX(es.score) as highest_score, + MIN(es.score) as lowest_score, + COUNT(CASE WHEN es.score >= COALESCE(e.passing_score, 0) THEN 1 END) as passed_count, + NOW() as last_updated +FROM exam_sessions es +JOIN users u ON es.user_id = u.id +JOIN exams e ON es.exam_id = e.id +WHERE es.status IN ('completed', 'submitted') + AND u.student_class IS NOT NULL +GROUP BY u.student_class, e.id, e.title; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_class_exam_perf_class_exam ON class_exam_performance (class_name, exam_id); diff --git a/scripts/schema_fingerprint_readonly.py b/scripts/schema_fingerprint_readonly.py new file mode 100644 index 0000000..b18b2fb --- /dev/null +++ b/scripts/schema_fingerprint_readonly.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Deterministic read-only public-schema fingerprint. + +The command never mutates the database. It opens a READ ONLY transaction, +hashes catalog metadata, and rolls back. Row data, password hashes, PII, and +unstable timestamps are excluded. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json + +from sqlalchemy import text + +from app.database import async_session_write, engine_write + +ALGORITHM = "sha256(canonical-json(public-schema-v1))" + +FINGERPRINT_QUERIES = { + "relation": """ + SELECT n.nspname, c.relname, c.relkind, + COALESCE(pg_get_partkeydef(c.oid), ''), + COALESCE(pg_get_expr(c.relpartbound, c.oid, true), '') + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND c.relkind IN ('r', 'p', 'v', 'm', 'S') + ORDER BY n.nspname, c.relname + """, + "column": """ + SELECT table_schema, table_name, ordinal_position, column_name, + data_type, udt_schema, udt_name, is_nullable, + COALESCE(column_default, ''), is_identity, identity_generation, + is_generated, COALESCE(generation_expression, '') + FROM information_schema.columns + WHERE table_schema = 'public' + ORDER BY table_schema, table_name, ordinal_position + """, + "index": """ + SELECT schemaname, tablename, indexname, indexdef + FROM pg_indexes + WHERE schemaname = 'public' + ORDER BY schemaname, tablename, indexname + """, + "constraint": """ + SELECT n.nspname, t.relname, c.conname, c.contype, + pg_get_constraintdef(c.oid, true) + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + WHERE n.nspname = 'public' + ORDER BY n.nspname, t.relname, c.conname + """, + "view": """ + SELECT n.nspname, c.relname, c.relkind, pg_get_viewdef(c.oid, true) + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relkind IN ('v', 'm') + ORDER BY n.nspname, c.relname + """, + "function": """ + SELECT n.nspname, p.proname, + pg_get_function_identity_arguments(p.oid), + pg_get_functiondef(p.oid) + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' + ORDER BY n.nspname, p.proname, + pg_get_function_identity_arguments(p.oid) + """, + "trigger": """ + SELECT n.nspname, c.relname, t.tgname, pg_get_triggerdef(t.oid, true) + FROM pg_trigger t + JOIN pg_class c ON c.oid = t.tgrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND NOT t.tgisinternal + ORDER BY n.nspname, c.relname, t.tgname + """, +} + + +def normalize_value(value: object) -> object: + if isinstance(value, bytes): + return value.decode("utf-8") + if value is None or isinstance(value, (bool, int, float, str)): + return value + return str(value) + + +def digest_records(records: list[list[object]]) -> str: + encoded = json.dumps(records, ensure_ascii=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + +async def collect_rows(session, sql: str) -> list[list[object]]: + result = await session.execute(text(sql)) + return [[normalize_value(value) for value in row] for row in result.fetchall()] + + +async def fingerprint_public_schema() -> dict[str, object]: + async with async_session_write() as session: + await session.execute(text("SET TRANSACTION READ ONLY")) + read_only = await session.execute(text("SHOW transaction_read_only")) + records: list[list[object]] = [] + counts: dict[str, int] = {} + for category, sql in FINGERPRINT_QUERIES.items(): + category_rows = await collect_rows(session, sql) + counts[category] = len(category_rows) + records.extend([[category, *row] for row in category_rows]) + await session.rollback() + + return { + "algorithm": ALGORITHM, + "sha256": digest_records(records), + "record_count": len(records), + "counts": counts, + "transaction_read_only": str(read_only.scalar_one()), + } + + +async def main() -> None: + report = await fingerprint_public_schema() + await engine_write.dispose() + print(json.dumps(report, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/test_migration_provenance.py b/tests/test_migration_provenance.py new file mode 100644 index 0000000..26ab1bc --- /dev/null +++ b/tests/test_migration_provenance.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ARCHIVE = ROOT / "docs" / "operations" / "migration-history" / "archive" +MANIFEST = ROOT / "docs" / "operations" / "migration-history" / "CONTROL_MANIFEST.json" +SANITIZED_MIGRATION = ( + ROOT / "app" / "migrations" / "20260423_developer_role_and_seed_accounts.sql" +) +FINGERPRINT_SCRIPT = ROOT / "scripts" / "schema_fingerprint_readonly.py" + +ARCHIVE_HASHES = { + "20260312_partition_exam_logs_and_hot_indexes.sql.txt": ( + "abb39dddd7dd3bd258c9b5352a62c9acf5b83d887fa90e49f3dfd118f580942f" + ), + "20260313_exam_logs_partition_maintenance.sql.txt": ( + "bcbbaafd4f7418478784e6e08581664999e6b4c202d8f113b4ee1a20a9761ec1" + ), + "20260418_users_role_guruplus.sql.txt": ( + "2eb9307fcde1368ded7b7da0c0f0c87669098bf8f5d2e8915bbdb639a7af04c0" + ), + "create_materialized_views.sql.txt": ( + "52c436fac5e46c615eb4f814d6082e91ca81698c49cddc9556566b5f4f1dd1b6" + ), +} + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def test_archive_files_match_production_hashes() -> None: + names = sorted(ARCHIVE_HASHES) + assert sorted(path.name for path in ARCHIVE.glob("*.sql.txt")) == names + for name, expected in ARCHIVE_HASHES.items(): + assert _sha256(ARCHIVE / name) == expected + + +def test_archive_files_are_inert_and_secret_free() -> None: + for path in ARCHIVE.glob("*.sql.txt"): + assert path.suffixes == [".sql", ".txt"] + assert "app/migrations" not in path.as_posix() + text = path.read_text(encoding="utf-8").lower() + assert "password_hash" not in text + assert "$2a$" not in text + assert "$2b$" not in text + assert "$2y$" not in text + + +def test_sanitized_developer_migration_has_no_credentials() -> None: + source = SANITIZED_MIGRATION.read_text(encoding="utf-8") + assert "INSERT INTO users" not in source + assert "password_hash" not in source + assert "$2a$" not in source + assert "$2b$" not in source + assert "$2y$" not in source + + +def test_fingerprint_script_is_read_only() -> None: + source = FINGERPRINT_SCRIPT.read_text(encoding="utf-8") + assert "SET TRANSACTION READ ONLY" in source + assert "session.rollback()" in source + assert "CREATE " not in source + assert "ALTER " not in source + assert "INSERT " not in source + assert "UPDATE " not in source + assert "DELETE " not in source + + +def test_control_manifest_exists_without_secrets() -> None: + payload = MANIFEST.read_text(encoding="utf-8") + assert "live-control-20260826-3f8fc938a226" in payload + assert "password" not in payload.lower() + assert "$2a$" not in payload + assert "$2b$" not in payload + assert "$2y$" not in payload From 373c13111ba74a9a0b1c4ba2112eec279e201eea Mon Sep 17 00:00:00 2001 From: SIAB1 Operations Date: Thu, 27 Aug 2026 23:28:29 +0700 Subject: [PATCH 4/8] ops: native Go autosave, batch, and submit with independent canary Finish remaining student write paths on Go with FastAPI-identical contracts, parity/A-B gates, and per-endpoint nginx canary maps. START/JOIN/ANSWER routing stays independent; FastAPI remains the fallback upstream. --- docker-compose.production.yml | 18 +- docker/Dockerfile.go | 7 +- docker/nginx.answer-canary-100pct.conf | 9 + docker/nginx.answer-canary-10pct.conf | 14 + docker/nginx.answer-canary-25pct.conf | 14 + docker/nginx.answer-canary-50pct.conf | 14 + docker/nginx.answer-canary-5pct.conf | 14 + docker/nginx.answer-canary-75pct.conf | 14 + docker/nginx.answer-canary-off.conf | 8 + docker/nginx.autosave-canary-100pct.conf | 9 + docker/nginx.autosave-canary-10pct.conf | 14 + docker/nginx.autosave-canary-25pct.conf | 14 + docker/nginx.autosave-canary-50pct.conf | 14 + docker/nginx.autosave-canary-5pct.conf | 14 + docker/nginx.autosave-canary-75pct.conf | 14 + docker/nginx.autosave-canary-off.conf | 8 + docker/nginx.batch-canary-100pct.conf | 9 + docker/nginx.batch-canary-10pct.conf | 14 + docker/nginx.batch-canary-25pct.conf | 14 + docker/nginx.batch-canary-50pct.conf | 14 + docker/nginx.batch-canary-5pct.conf | 14 + docker/nginx.batch-canary-75pct.conf | 14 + docker/nginx.batch-canary-off.conf | 8 + docker/nginx.join-canary-100pct.conf | 10 + docker/nginx.join-canary-10pct.conf | 15 + docker/nginx.join-canary-25pct.conf | 15 + docker/nginx.join-canary-50pct.conf | 15 + docker/nginx.join-canary-5pct.conf | 15 + docker/nginx.join-canary-75pct.conf | 15 + docker/nginx.join-canary-off.conf | 9 + docker/nginx.production.conf | 83 +- docker/nginx.start-canary-100pct.conf | 10 + docker/nginx.start-canary-10pct.conf | 15 + docker/nginx.start-canary-25pct.conf | 15 + docker/nginx.start-canary-50pct.conf | 15 + docker/nginx.start-canary-5pct.conf | 15 + docker/nginx.start-canary-75pct.conf | 15 + docker/nginx.start-canary-off.conf | 9 + docker/nginx.submit-canary-100pct.conf | 9 + docker/nginx.submit-canary-10pct.conf | 14 + docker/nginx.submit-canary-25pct.conf | 14 + docker/nginx.submit-canary-50pct.conf | 14 + docker/nginx.submit-canary-5pct.conf | 14 + docker/nginx.submit-canary-75pct.conf | 14 + docker/nginx.submit-canary-off.conf | 8 + go/cmd/server/main.go | 4 +- go/go.mod | 3 + go/go.sum | 10 + go/internal/auth/jwt.go | 62 +- go/internal/config/config.go | 22 + go/internal/exam/answer_native.go | 492 +++++++++ go/internal/exam/answer_native_test.go | 275 +++++ go/internal/exam/autosave_native.go | 355 +++++++ go/internal/exam/http.go | 65 +- go/internal/exam/join.go | 80 +- go/internal/exam/join_native.go | 183 ++++ go/internal/exam/join_native_test.go | 393 ++++++++ go/internal/exam/runtime.go | 4 +- go/internal/exam/start.go | 36 - go/internal/exam/start_admission.go | 90 ++ go/internal/exam/start_builder.go | 273 +++++ go/internal/exam/start_native.go | 715 +++++++++++++ go/internal/exam/start_native_test.go | 946 ++++++++++++++++++ go/internal/exam/start_security.go | 307 ++++++ go/internal/exam/start_shuffle.go | 159 +++ go/internal/exam/start_shuffle_test.go | 26 + go/internal/exam/submit.go | 7 - go/internal/exam/submit_native.go | 327 ++++++ .../exam/testdata/fastapi_start_parity.json | 27 + go/internal/httpserver/hotpath_test.go | 32 +- go/internal/httpserver/server.go | 2 +- go/internal/persistence/answer_native.go | 373 +++++++ go/internal/persistence/autosave_batch.go | 338 +++++++ go/internal/persistence/exam.go | 49 +- go/internal/persistence/join_native.go | 92 ++ go/internal/persistence/persistence.go | 43 +- go/internal/persistence/persistence_test.go | 26 + go/internal/persistence/start_native.go | 522 ++++++++++ go/internal/persistence/submit_native.go | 310 ++++++ go/internal/security/headers.go | 6 +- go/internal/security/sxb.go | 8 +- runtime_control/nginx.answer-canary.conf | 8 + runtime_control/nginx.autosave-canary.conf | 8 + runtime_control/nginx.batch-canary.conf | 8 + runtime_control/nginx.join-canary.conf | 9 + runtime_control/nginx.start-canary.conf | 9 + runtime_control/nginx.submit-canary.conf | 8 + scripts/go_answer_canary_control.sh | 72 ++ scripts/go_answer_stage0.py | 290 ++++++ scripts/go_autosave_canary_control.sh | 72 ++ scripts/go_batch_canary_control.sh | 72 ++ scripts/go_hotpath_lifecycle.py | 263 +++++ scripts/go_join_canary_control.sh | 72 ++ scripts/go_join_stage0.py | 279 ++++++ scripts/go_remaining_stage0.py | 280 ++++++ scripts/go_start_canary_control.sh | 73 ++ scripts/go_start_stage0.py | 465 +++++++++ scripts/go_submit_canary_control.sh | 72 ++ scripts/run_answer_parity_ab.py | 614 ++++++++++++ scripts/run_join_parity_ab.py | 610 +++++++++++ scripts/run_remaining_hotpath_parity_ab.py | 639 ++++++++++++ tests/test_go_answer_native.py | 26 + tests/test_go_exam_write_proxy.py | 21 +- tests/test_go_join_canary_routing.py | 55 + tests/test_go_join_native.py | 43 + tests/test_go_start_canary_routing.py | 50 + tests/test_go_start_parity_fixture.py | 216 ++++ tests/test_nginx_static_offload.py | 17 +- 108 files changed, 11437 insertions(+), 220 deletions(-) create mode 100644 docker/nginx.answer-canary-100pct.conf create mode 100644 docker/nginx.answer-canary-10pct.conf create mode 100644 docker/nginx.answer-canary-25pct.conf create mode 100644 docker/nginx.answer-canary-50pct.conf create mode 100644 docker/nginx.answer-canary-5pct.conf create mode 100644 docker/nginx.answer-canary-75pct.conf create mode 100644 docker/nginx.answer-canary-off.conf create mode 100644 docker/nginx.autosave-canary-100pct.conf create mode 100644 docker/nginx.autosave-canary-10pct.conf create mode 100644 docker/nginx.autosave-canary-25pct.conf create mode 100644 docker/nginx.autosave-canary-50pct.conf create mode 100644 docker/nginx.autosave-canary-5pct.conf create mode 100644 docker/nginx.autosave-canary-75pct.conf create mode 100644 docker/nginx.autosave-canary-off.conf create mode 100644 docker/nginx.batch-canary-100pct.conf create mode 100644 docker/nginx.batch-canary-10pct.conf create mode 100644 docker/nginx.batch-canary-25pct.conf create mode 100644 docker/nginx.batch-canary-50pct.conf create mode 100644 docker/nginx.batch-canary-5pct.conf create mode 100644 docker/nginx.batch-canary-75pct.conf create mode 100644 docker/nginx.batch-canary-off.conf create mode 100644 docker/nginx.join-canary-100pct.conf create mode 100644 docker/nginx.join-canary-10pct.conf create mode 100644 docker/nginx.join-canary-25pct.conf create mode 100644 docker/nginx.join-canary-50pct.conf create mode 100644 docker/nginx.join-canary-5pct.conf create mode 100644 docker/nginx.join-canary-75pct.conf create mode 100644 docker/nginx.join-canary-off.conf create mode 100644 docker/nginx.start-canary-100pct.conf create mode 100644 docker/nginx.start-canary-10pct.conf create mode 100644 docker/nginx.start-canary-25pct.conf create mode 100644 docker/nginx.start-canary-50pct.conf create mode 100644 docker/nginx.start-canary-5pct.conf create mode 100644 docker/nginx.start-canary-75pct.conf create mode 100644 docker/nginx.start-canary-off.conf create mode 100644 docker/nginx.submit-canary-100pct.conf create mode 100644 docker/nginx.submit-canary-10pct.conf create mode 100644 docker/nginx.submit-canary-25pct.conf create mode 100644 docker/nginx.submit-canary-50pct.conf create mode 100644 docker/nginx.submit-canary-5pct.conf create mode 100644 docker/nginx.submit-canary-75pct.conf create mode 100644 docker/nginx.submit-canary-off.conf create mode 100644 go/internal/exam/answer_native.go create mode 100644 go/internal/exam/answer_native_test.go create mode 100644 go/internal/exam/autosave_native.go create mode 100644 go/internal/exam/join_native.go create mode 100644 go/internal/exam/join_native_test.go create mode 100644 go/internal/exam/start_admission.go create mode 100644 go/internal/exam/start_builder.go create mode 100644 go/internal/exam/start_native.go create mode 100644 go/internal/exam/start_native_test.go create mode 100644 go/internal/exam/start_security.go create mode 100644 go/internal/exam/start_shuffle.go create mode 100644 go/internal/exam/start_shuffle_test.go delete mode 100644 go/internal/exam/submit.go create mode 100644 go/internal/exam/submit_native.go create mode 100644 go/internal/exam/testdata/fastapi_start_parity.json create mode 100644 go/internal/persistence/answer_native.go create mode 100644 go/internal/persistence/autosave_batch.go create mode 100644 go/internal/persistence/join_native.go create mode 100644 go/internal/persistence/persistence_test.go create mode 100644 go/internal/persistence/start_native.go create mode 100644 go/internal/persistence/submit_native.go create mode 100644 runtime_control/nginx.answer-canary.conf create mode 100644 runtime_control/nginx.autosave-canary.conf create mode 100644 runtime_control/nginx.batch-canary.conf create mode 100644 runtime_control/nginx.join-canary.conf create mode 100644 runtime_control/nginx.start-canary.conf create mode 100644 runtime_control/nginx.submit-canary.conf create mode 100644 scripts/go_answer_canary_control.sh create mode 100644 scripts/go_answer_stage0.py create mode 100755 scripts/go_autosave_canary_control.sh create mode 100755 scripts/go_batch_canary_control.sh create mode 100644 scripts/go_hotpath_lifecycle.py create mode 100755 scripts/go_join_canary_control.sh create mode 100755 scripts/go_join_stage0.py create mode 100644 scripts/go_remaining_stage0.py create mode 100755 scripts/go_start_canary_control.sh create mode 100755 scripts/go_start_stage0.py create mode 100755 scripts/go_submit_canary_control.sh create mode 100644 scripts/run_answer_parity_ab.py create mode 100644 scripts/run_join_parity_ab.py create mode 100644 scripts/run_remaining_hotpath_parity_ab.py create mode 100644 tests/test_go_answer_native.py create mode 100644 tests/test_go_join_canary_routing.py create mode 100644 tests/test_go_join_native.py create mode 100644 tests/test_go_start_canary_routing.py create mode 100644 tests/test_go_start_parity_fixture.py diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 79bb929..86295dc 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -284,6 +284,12 @@ services: - "127.0.0.1:${SIAB1_ORIGIN_PORT:-8080}:80" volumes: - ./docker/nginx.production.conf:/etc/nginx/nginx.conf:ro + - ./runtime_control/nginx.start-canary.conf:/etc/nginx/start-canary.conf:ro + - ./runtime_control/nginx.join-canary.conf:/etc/nginx/join-canary.conf:ro + - ./runtime_control/nginx.answer-canary.conf:/etc/nginx/answer-canary.conf:ro + - ./runtime_control/nginx.autosave-canary.conf:/etc/nginx/autosave-canary.conf:ro + - ./runtime_control/nginx.batch-canary.conf:/etc/nginx/batch-canary.conf:ro + - ./runtime_control/nginx.submit-canary.conf:/etc/nginx/submit-canary.conf:ro - ./static:/usr/share/nginx/html/static:ro - ./templates:/usr/share/nginx/html/templates:ro depends_on: @@ -443,19 +449,22 @@ services: # docker compose -f docker-compose.production.yml --profile native-lean up -d go_server go_worker go_server: profiles: ["native-lean"] - image: siab1-go + image: siab1-go:${GO_CANDIDATE_SHA:-candidate} build: context: . dockerfile: docker/Dockerfile.go args: BIN: server + REVISION: ${GO_CANDIDATE_SHA:-unknown} environment: - - DATABASE_URL=postgresql://examuser:${DB_PASSWORD:-${POSTGRES_PASSWORD:-}}@pgbouncer:6432/siab1 + - DATABASE_URL=postgresql://examuser:${DB_PASSWORD:-${POSTGRES_PASSWORD:-}}@pgbouncer:6432/siab1?pool_max_conns=4&default_query_exec_mode=simple_protocol&statement_cache_capacity=0 - REDIS_URL=redis://redis:6379/0 - JWT_SECRET_KEY=${JWT_SECRET_KEY} - SECRET_KEY=${SECRET_KEY} - EXAM_PEAK_MODE=${EXAM_PEAK_MODE:-true} - ENFORCE_SXB=true + - START_DB_ADMISSION_LIMIT=4 + - SIAB_REPLICA=go-start - CORS_ORIGINS=${CORS_ORIGINS} - PYTHON_UPSTREAM=http://api:8000 - STATIC_DIR=/app/static @@ -482,14 +491,15 @@ services: go_worker: profiles: ["native-lean"] - image: siab1-go + image: siab1-go:${GO_CANDIDATE_SHA:-candidate} build: context: . dockerfile: docker/Dockerfile.go args: BIN: worker + REVISION: ${GO_CANDIDATE_SHA:-unknown} environment: - - DATABASE_URL=postgresql://examuser:${DB_PASSWORD:-${POSTGRES_PASSWORD:-}}@pgbouncer:6432/siab1 + - DATABASE_URL=postgresql://examuser:${DB_PASSWORD:-${POSTGRES_PASSWORD:-}}@pgbouncer:6432/siab1?pool_max_conns=4&default_query_exec_mode=simple_protocol&statement_cache_capacity=0 - REDIS_URL=redis://redis:6379/0 - TZ=Asia/Jakarta depends_on: diff --git a/docker/Dockerfile.go b/docker/Dockerfile.go index 5a3638a..4172bd7 100644 --- a/docker/Dockerfile.go +++ b/docker/Dockerfile.go @@ -4,9 +4,14 @@ ENV GOPROXY=https://proxy.golang.org,direct \ GOTOOLCHAIN=local COPY go/ . ARG BIN=server -RUN CGO_ENABLED=0 GOOS=linux go build -o /out/app ./cmd/${BIN} +ARG REVISION=unknown +RUN CGO_ENABLED=0 GOOS=linux go build -mod=readonly -trimpath -buildvcs=false \ + -ldflags="-s -w -buildid= -X main.revision=${REVISION}" \ + -o /out/app ./cmd/${BIN} FROM alpine:3.20 +ARG REVISION=unknown +LABEL org.opencontainers.image.revision="${REVISION}" WORKDIR /app RUN apk add --no-cache ca-certificates tzdata wget COPY --from=build /out/app /app/app diff --git a/docker/nginx.answer-canary-100pct.conf b/docker/nginx.answer-canary-100pct.conf new file mode 100644 index 0000000..ae70b78 --- /dev/null +++ b/docker/nginx.answer-canary-100pct.conf @@ -0,0 +1,9 @@ +map $request_uri $go_answer_canary { + default fastapi; + ~^/api/exams/submit-answer(\?|$) go; +} + +map $go_answer_canary $answer_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.answer-canary-10pct.conf b/docker/nginx.answer-canary-10pct.conf new file mode 100644 index 0000000..a819e30 --- /dev/null +++ b/docker/nginx.answer-canary-10pct.conf @@ -0,0 +1,14 @@ +split_clients "$http_authorization" $go_answer_cohort { + 10% go; + * fastapi; +} + +map $request_uri $go_answer_canary { + default fastapi; + ~^/api/exams/submit-answer(\?|$) $go_answer_cohort; +} + +map $go_answer_canary $answer_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.answer-canary-25pct.conf b/docker/nginx.answer-canary-25pct.conf new file mode 100644 index 0000000..b3e1ae8 --- /dev/null +++ b/docker/nginx.answer-canary-25pct.conf @@ -0,0 +1,14 @@ +split_clients "$http_authorization" $go_answer_cohort { + 25% go; + * fastapi; +} + +map $request_uri $go_answer_canary { + default fastapi; + ~^/api/exams/submit-answer(\?|$) $go_answer_cohort; +} + +map $go_answer_canary $answer_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.answer-canary-50pct.conf b/docker/nginx.answer-canary-50pct.conf new file mode 100644 index 0000000..93a010d --- /dev/null +++ b/docker/nginx.answer-canary-50pct.conf @@ -0,0 +1,14 @@ +split_clients "$http_authorization" $go_answer_cohort { + 50% go; + * fastapi; +} + +map $request_uri $go_answer_canary { + default fastapi; + ~^/api/exams/submit-answer(\?|$) $go_answer_cohort; +} + +map $go_answer_canary $answer_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.answer-canary-5pct.conf b/docker/nginx.answer-canary-5pct.conf new file mode 100644 index 0000000..ca9fcd2 --- /dev/null +++ b/docker/nginx.answer-canary-5pct.conf @@ -0,0 +1,14 @@ +split_clients "$http_authorization" $go_answer_cohort { + 5% go; + * fastapi; +} + +map $request_uri $go_answer_canary { + default fastapi; + ~^/api/exams/submit-answer(\?|$) $go_answer_cohort; +} + +map $go_answer_canary $answer_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.answer-canary-75pct.conf b/docker/nginx.answer-canary-75pct.conf new file mode 100644 index 0000000..eba6735 --- /dev/null +++ b/docker/nginx.answer-canary-75pct.conf @@ -0,0 +1,14 @@ +split_clients "$http_authorization" $go_answer_cohort { + 75% go; + * fastapi; +} + +map $request_uri $go_answer_canary { + default fastapi; + ~^/api/exams/submit-answer(\?|$) $go_answer_cohort; +} + +map $go_answer_canary $answer_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.answer-canary-off.conf b/docker/nginx.answer-canary-off.conf new file mode 100644 index 0000000..33863d9 --- /dev/null +++ b/docker/nginx.answer-canary-off.conf @@ -0,0 +1,8 @@ +map $request_uri $go_answer_canary { + default fastapi; +} + +map $go_answer_canary $answer_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.autosave-canary-100pct.conf b/docker/nginx.autosave-canary-100pct.conf new file mode 100644 index 0000000..58d4828 --- /dev/null +++ b/docker/nginx.autosave-canary-100pct.conf @@ -0,0 +1,9 @@ +map $request_uri $go_autosave_canary { + default fastapi; + ~^/api/exams/auto-save(\?|$) go; +} + +map $go_autosave_canary $autosave_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.autosave-canary-10pct.conf b/docker/nginx.autosave-canary-10pct.conf new file mode 100644 index 0000000..6241a9f --- /dev/null +++ b/docker/nginx.autosave-canary-10pct.conf @@ -0,0 +1,14 @@ +split_clients "$http_authorization" $go_autosave_cohort { + 10% go; + * fastapi; +} + +map $request_uri $go_autosave_canary { + default fastapi; + ~^/api/exams/auto-save(\?|$) $go_autosave_cohort; +} + +map $go_autosave_canary $autosave_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.autosave-canary-25pct.conf b/docker/nginx.autosave-canary-25pct.conf new file mode 100644 index 0000000..606fd92 --- /dev/null +++ b/docker/nginx.autosave-canary-25pct.conf @@ -0,0 +1,14 @@ +split_clients "$http_authorization" $go_autosave_cohort { + 25% go; + * fastapi; +} + +map $request_uri $go_autosave_canary { + default fastapi; + ~^/api/exams/auto-save(\?|$) $go_autosave_cohort; +} + +map $go_autosave_canary $autosave_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.autosave-canary-50pct.conf b/docker/nginx.autosave-canary-50pct.conf new file mode 100644 index 0000000..12ae646 --- /dev/null +++ b/docker/nginx.autosave-canary-50pct.conf @@ -0,0 +1,14 @@ +split_clients "$http_authorization" $go_autosave_cohort { + 50% go; + * fastapi; +} + +map $request_uri $go_autosave_canary { + default fastapi; + ~^/api/exams/auto-save(\?|$) $go_autosave_cohort; +} + +map $go_autosave_canary $autosave_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.autosave-canary-5pct.conf b/docker/nginx.autosave-canary-5pct.conf new file mode 100644 index 0000000..53276d6 --- /dev/null +++ b/docker/nginx.autosave-canary-5pct.conf @@ -0,0 +1,14 @@ +split_clients "$http_authorization" $go_autosave_cohort { + 5% go; + * fastapi; +} + +map $request_uri $go_autosave_canary { + default fastapi; + ~^/api/exams/auto-save(\?|$) $go_autosave_cohort; +} + +map $go_autosave_canary $autosave_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.autosave-canary-75pct.conf b/docker/nginx.autosave-canary-75pct.conf new file mode 100644 index 0000000..5056fe8 --- /dev/null +++ b/docker/nginx.autosave-canary-75pct.conf @@ -0,0 +1,14 @@ +split_clients "$http_authorization" $go_autosave_cohort { + 75% go; + * fastapi; +} + +map $request_uri $go_autosave_canary { + default fastapi; + ~^/api/exams/auto-save(\?|$) $go_autosave_cohort; +} + +map $go_autosave_canary $autosave_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.autosave-canary-off.conf b/docker/nginx.autosave-canary-off.conf new file mode 100644 index 0000000..94647d5 --- /dev/null +++ b/docker/nginx.autosave-canary-off.conf @@ -0,0 +1,8 @@ +map $request_uri $go_autosave_canary { + default fastapi; +} + +map $go_autosave_canary $autosave_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.batch-canary-100pct.conf b/docker/nginx.batch-canary-100pct.conf new file mode 100644 index 0000000..df7cb66 --- /dev/null +++ b/docker/nginx.batch-canary-100pct.conf @@ -0,0 +1,9 @@ +map $request_uri $go_batch_canary { + default fastapi; + ~^/api/exams/auto-save-batch(\?|$) go; +} + +map $go_batch_canary $batch_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.batch-canary-10pct.conf b/docker/nginx.batch-canary-10pct.conf new file mode 100644 index 0000000..f4f3118 --- /dev/null +++ b/docker/nginx.batch-canary-10pct.conf @@ -0,0 +1,14 @@ +split_clients "$http_authorization" $go_batch_cohort { + 10% go; + * fastapi; +} + +map $request_uri $go_batch_canary { + default fastapi; + ~^/api/exams/auto-save-batch(\?|$) $go_batch_cohort; +} + +map $go_batch_canary $batch_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.batch-canary-25pct.conf b/docker/nginx.batch-canary-25pct.conf new file mode 100644 index 0000000..a327d88 --- /dev/null +++ b/docker/nginx.batch-canary-25pct.conf @@ -0,0 +1,14 @@ +split_clients "$http_authorization" $go_batch_cohort { + 25% go; + * fastapi; +} + +map $request_uri $go_batch_canary { + default fastapi; + ~^/api/exams/auto-save-batch(\?|$) $go_batch_cohort; +} + +map $go_batch_canary $batch_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.batch-canary-50pct.conf b/docker/nginx.batch-canary-50pct.conf new file mode 100644 index 0000000..ad4eee4 --- /dev/null +++ b/docker/nginx.batch-canary-50pct.conf @@ -0,0 +1,14 @@ +split_clients "$http_authorization" $go_batch_cohort { + 50% go; + * fastapi; +} + +map $request_uri $go_batch_canary { + default fastapi; + ~^/api/exams/auto-save-batch(\?|$) $go_batch_cohort; +} + +map $go_batch_canary $batch_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.batch-canary-5pct.conf b/docker/nginx.batch-canary-5pct.conf new file mode 100644 index 0000000..f55d289 --- /dev/null +++ b/docker/nginx.batch-canary-5pct.conf @@ -0,0 +1,14 @@ +split_clients "$http_authorization" $go_batch_cohort { + 5% go; + * fastapi; +} + +map $request_uri $go_batch_canary { + default fastapi; + ~^/api/exams/auto-save-batch(\?|$) $go_batch_cohort; +} + +map $go_batch_canary $batch_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.batch-canary-75pct.conf b/docker/nginx.batch-canary-75pct.conf new file mode 100644 index 0000000..ea169fb --- /dev/null +++ b/docker/nginx.batch-canary-75pct.conf @@ -0,0 +1,14 @@ +split_clients "$http_authorization" $go_batch_cohort { + 75% go; + * fastapi; +} + +map $request_uri $go_batch_canary { + default fastapi; + ~^/api/exams/auto-save-batch(\?|$) $go_batch_cohort; +} + +map $go_batch_canary $batch_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.batch-canary-off.conf b/docker/nginx.batch-canary-off.conf new file mode 100644 index 0000000..59dc94b --- /dev/null +++ b/docker/nginx.batch-canary-off.conf @@ -0,0 +1,8 @@ +map $request_uri $go_batch_canary { + default fastapi; +} + +map $go_batch_canary $batch_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.join-canary-100pct.conf b/docker/nginx.join-canary-100pct.conf new file mode 100644 index 0000000..0e091b5 --- /dev/null +++ b/docker/nginx.join-canary-100pct.conf @@ -0,0 +1,10 @@ +# All JOIN traffic to Go. FastAPI remains the upstream backup only. +map $request_uri $go_join_canary { + default fastapi; + ~^/api/exams/join(\?|$) go; +} + +map $go_join_canary $join_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.join-canary-10pct.conf b/docker/nginx.join-canary-10pct.conf new file mode 100644 index 0000000..37ab8aa --- /dev/null +++ b/docker/nginx.join-canary-10pct.conf @@ -0,0 +1,15 @@ +# Stable ten-percent JOIN cohort based on the bearer token. +split_clients "$http_authorization" $go_join_cohort { + 10% go; + * fastapi; +} + +map $request_uri $go_join_canary { + default fastapi; + ~^/api/exams/join(\?|$) $go_join_cohort; +} + +map $go_join_canary $join_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.join-canary-25pct.conf b/docker/nginx.join-canary-25pct.conf new file mode 100644 index 0000000..b7836ee --- /dev/null +++ b/docker/nginx.join-canary-25pct.conf @@ -0,0 +1,15 @@ +# Stable twenty-five-percent JOIN cohort based on the bearer token. +split_clients "$http_authorization" $go_join_cohort { + 25% go; + * fastapi; +} + +map $request_uri $go_join_canary { + default fastapi; + ~^/api/exams/join(\?|$) $go_join_cohort; +} + +map $go_join_canary $join_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.join-canary-50pct.conf b/docker/nginx.join-canary-50pct.conf new file mode 100644 index 0000000..2903051 --- /dev/null +++ b/docker/nginx.join-canary-50pct.conf @@ -0,0 +1,15 @@ +# Stable fifty-percent JOIN cohort based on the bearer token. +split_clients "$http_authorization" $go_join_cohort { + 50% go; + * fastapi; +} + +map $request_uri $go_join_canary { + default fastapi; + ~^/api/exams/join(\?|$) $go_join_cohort; +} + +map $go_join_canary $join_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.join-canary-5pct.conf b/docker/nginx.join-canary-5pct.conf new file mode 100644 index 0000000..bbb3a65 --- /dev/null +++ b/docker/nginx.join-canary-5pct.conf @@ -0,0 +1,15 @@ +# Stable five-percent JOIN cohort based on the bearer token. +split_clients "$http_authorization" $go_join_cohort { + 5% go; + * fastapi; +} + +map $request_uri $go_join_canary { + default fastapi; + ~^/api/exams/join(\?|$) $go_join_cohort; +} + +map $go_join_canary $join_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.join-canary-75pct.conf b/docker/nginx.join-canary-75pct.conf new file mode 100644 index 0000000..b4e75c0 --- /dev/null +++ b/docker/nginx.join-canary-75pct.conf @@ -0,0 +1,15 @@ +# Stable seventy-five-percent JOIN cohort based on the bearer token. +split_clients "$http_authorization" $go_join_cohort { + 75% go; + * fastapi; +} + +map $request_uri $go_join_canary { + default fastapi; + ~^/api/exams/join(\?|$) $go_join_cohort; +} + +map $go_join_canary $join_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.join-canary-off.conf b/docker/nginx.join-canary-off.conf new file mode 100644 index 0000000..32a6279 --- /dev/null +++ b/docker/nginx.join-canary-off.conf @@ -0,0 +1,9 @@ +# Fail-safe default: every JOIN request stays on FastAPI. +map $request_uri $go_join_canary { + default fastapi; +} + +map $go_join_canary $join_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.production.conf b/docker/nginx.production.conf index ed8a3d6..0cc5435 100644 --- a/docker/nginx.production.conf +++ b/docker/nginx.production.conf @@ -21,6 +21,12 @@ events { # Re-resolve Docker service DNS records so upstream IP changes after # container restarts do not leave stale destinations in Nginx workers. resolver 127.0.0.11 valid=10s ipv6=off; + include /etc/nginx/start-canary.conf; + include /etc/nginx/join-canary.conf; + include /etc/nginx/answer-canary.conf; + include /etc/nginx/autosave-canary.conf; + include /etc/nginx/batch-canary.conf; + include /etc/nginx/submit-canary.conf; # Logging log_format main '$remote_addr - $remote_user [$time_local] "$request" ' @@ -28,8 +34,14 @@ events { '"$http_user_agent" "$http_x_forwarded_for" ' 'rt=$request_time uct="$upstream_connect_time" ' 'uht="$upstream_header_time" urt="$upstream_response_time" ' - 'ua="$upstream_addr" us="$upstream_status" ' - 'ur="$upstream_http_x_siab_replica"'; + 'ua="$upstream_addr" us="$upstream_status" ' + 'ur="$upstream_http_x_siab_replica" ' + 'sr="$go_start_canary" ' + 'jr="$go_join_canary" ' + 'ar="$go_answer_canary" ' + 'as="$go_autosave_canary" ' + 'ab="$go_batch_canary" ' + 'su="$go_submit_canary"'; access_log /var/log/nginx/access.log main; @@ -136,6 +148,14 @@ events { keepalive 128; } + # START-only Go canary. FastAPI remains the immediate retry target. + upstream go_start_backend { + zone go_start_backend 64k; + server go_server:8000 resolve max_fails=1 fail_timeout=1h; + server api:8000 resolve backup; + keepalive 64; + } + server { listen 80 default_server; server_name siab.man1rokanhulu.cloud; @@ -477,13 +497,16 @@ events { limit_req zone=join_limit burst=1800 nodelay; limit_req_status 429; - proxy_pass http://fastapi_backend; + proxy_pass http://$join_backend; proxy_set_header Host $host; proxy_set_header X-Forwarded-Host $host; proxy_set_header CF-Connecting-IP ""; proxy_set_header X-Real-IP $client_real_ip; proxy_set_header X-Forwarded-For $client_real_ip; proxy_set_header X-Forwarded-Proto $client_scheme; + proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504 non_idempotent; + proxy_next_upstream_tries 2; + proxy_next_upstream_timeout 15s; proxy_connect_timeout 30s; proxy_send_timeout 180s; proxy_read_timeout 180s; @@ -494,7 +517,7 @@ events { limit_req zone=start_limit burst=2000 nodelay; limit_req_status 429; - proxy_pass http://fastapi_backend; + proxy_pass http://$start_backend; proxy_set_header Host $host; proxy_set_header X-Forwarded-Host $host; proxy_set_header CF-Connecting-IP ""; @@ -504,7 +527,7 @@ events { # Exam start is idempotent because the backend resumes an active # session instead of creating duplicates. Retry transient 503/504 # from a saturated replica on another upstream. - proxy_next_upstream error timeout invalid_header http_502 http_503 http_504 non_idempotent; + proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504 non_idempotent; proxy_next_upstream_tries 2; proxy_next_upstream_timeout 15s; proxy_connect_timeout 10s; @@ -644,7 +667,45 @@ events { limit_req zone=submit_limit burst=4000; limit_req_status 429; - proxy_pass http://fastapi_backend; + proxy_pass http://$answer_backend; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header CF-Connecting-IP ""; + proxy_set_header X-Real-IP $client_real_ip; + proxy_set_header X-Forwarded-For $client_real_ip; + proxy_set_header X-Forwarded-Proto $client_scheme; + proxy_next_upstream error timeout invalid_header http_502 http_503 http_504 non_idempotent; + proxy_next_upstream_tries 2; + proxy_next_upstream_timeout 12s; + proxy_connect_timeout 10s; + proxy_send_timeout 90s; + proxy_read_timeout 90s; + } + + location = /api/exams/auto-save { + limit_req zone=submit_limit burst=4000; + limit_req_status 429; + + proxy_pass http://$autosave_backend; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header CF-Connecting-IP ""; + proxy_set_header X-Real-IP $client_real_ip; + proxy_set_header X-Forwarded-For $client_real_ip; + proxy_set_header X-Forwarded-Proto $client_scheme; + proxy_next_upstream error timeout invalid_header http_502 http_503 http_504 non_idempotent; + proxy_next_upstream_tries 2; + proxy_next_upstream_timeout 12s; + proxy_connect_timeout 10s; + proxy_send_timeout 90s; + proxy_read_timeout 90s; + } + + location = /api/exams/auto-save-batch { + limit_req zone=submit_limit burst=4000; + limit_req_status 429; + + proxy_pass http://$batch_backend; proxy_set_header Host $host; proxy_set_header X-Forwarded-Host $host; proxy_set_header CF-Connecting-IP ""; @@ -665,7 +726,7 @@ events { limit_req zone=submit_limit burst=4000; limit_req_status 429; - proxy_pass http://fastapi_backend; + proxy_pass http://$submit_backend; proxy_set_header Host $host; proxy_set_header X-Forwarded-Host $host; proxy_set_header CF-Connecting-IP ""; @@ -713,13 +774,17 @@ events { proxy_send_timeout 86400; } - # Metrics are scraped directly over the private Docker network. - # Never expose process/runtime details through the public proxy. + # Metrics and process internals stay off the public proxy. location ^~ /metrics { access_log off; return 404; } + location ^~ /internal/ { + access_log off; + return 404; + } + # Health check (no rate limit) location /health { proxy_pass http://fastapi_backend; diff --git a/docker/nginx.start-canary-100pct.conf b/docker/nginx.start-canary-100pct.conf new file mode 100644 index 0000000..66408ac --- /dev/null +++ b/docker/nginx.start-canary-100pct.conf @@ -0,0 +1,10 @@ +# All START traffic to Go. FastAPI remains the upstream backup only. +map $request_uri $go_start_canary { + default fastapi; + ~^/api/exams/[0-9]+/start$ go; +} + +map $go_start_canary $start_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.start-canary-10pct.conf b/docker/nginx.start-canary-10pct.conf new file mode 100644 index 0000000..18474d5 --- /dev/null +++ b/docker/nginx.start-canary-10pct.conf @@ -0,0 +1,15 @@ +# Stable ten-percent START cohort based on the bearer token. +split_clients "$http_authorization" $go_start_cohort { + 10% go; + * fastapi; +} + +map $request_uri $go_start_canary { + default fastapi; + ~^/api/exams/[0-9]+/start$ $go_start_cohort; +} + +map $go_start_canary $start_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.start-canary-25pct.conf b/docker/nginx.start-canary-25pct.conf new file mode 100644 index 0000000..5a47568 --- /dev/null +++ b/docker/nginx.start-canary-25pct.conf @@ -0,0 +1,15 @@ +# Stable twenty-five-percent START cohort based on the bearer token. +split_clients "$http_authorization" $go_start_cohort { + 25% go; + * fastapi; +} + +map $request_uri $go_start_canary { + default fastapi; + ~^/api/exams/[0-9]+/start$ $go_start_cohort; +} + +map $go_start_canary $start_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.start-canary-50pct.conf b/docker/nginx.start-canary-50pct.conf new file mode 100644 index 0000000..19c38c1 --- /dev/null +++ b/docker/nginx.start-canary-50pct.conf @@ -0,0 +1,15 @@ +# Stable fifty-percent START cohort based on the bearer token. +split_clients "$http_authorization" $go_start_cohort { + 50% go; + * fastapi; +} + +map $request_uri $go_start_canary { + default fastapi; + ~^/api/exams/[0-9]+/start$ $go_start_cohort; +} + +map $go_start_canary $start_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.start-canary-5pct.conf b/docker/nginx.start-canary-5pct.conf new file mode 100644 index 0000000..250faaf --- /dev/null +++ b/docker/nginx.start-canary-5pct.conf @@ -0,0 +1,15 @@ +# Stable five-percent START cohort based on the bearer token. +split_clients "$http_authorization" $go_start_cohort { + 5% go; + * fastapi; +} + +map $request_uri $go_start_canary { + default fastapi; + ~^/api/exams/[0-9]+/start$ $go_start_cohort; +} + +map $go_start_canary $start_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.start-canary-75pct.conf b/docker/nginx.start-canary-75pct.conf new file mode 100644 index 0000000..25ffba5 --- /dev/null +++ b/docker/nginx.start-canary-75pct.conf @@ -0,0 +1,15 @@ +# Stable seventy-five-percent START cohort based on the bearer token. +split_clients "$http_authorization" $go_start_cohort { + 75% go; + * fastapi; +} + +map $request_uri $go_start_canary { + default fastapi; + ~^/api/exams/[0-9]+/start$ $go_start_cohort; +} + +map $go_start_canary $start_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.start-canary-off.conf b/docker/nginx.start-canary-off.conf new file mode 100644 index 0000000..95ee28b --- /dev/null +++ b/docker/nginx.start-canary-off.conf @@ -0,0 +1,9 @@ +# Fail-safe default: every START request stays on FastAPI. +map $request_uri $go_start_canary { + default fastapi; +} + +map $go_start_canary $start_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.submit-canary-100pct.conf b/docker/nginx.submit-canary-100pct.conf new file mode 100644 index 0000000..be4e311 --- /dev/null +++ b/docker/nginx.submit-canary-100pct.conf @@ -0,0 +1,9 @@ +map $request_uri $go_submit_canary { + default fastapi; + ~^/api/exams/submit(\?|$) go; +} + +map $go_submit_canary $submit_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.submit-canary-10pct.conf b/docker/nginx.submit-canary-10pct.conf new file mode 100644 index 0000000..fa494a7 --- /dev/null +++ b/docker/nginx.submit-canary-10pct.conf @@ -0,0 +1,14 @@ +split_clients "$http_authorization" $go_submit_cohort { + 10% go; + * fastapi; +} + +map $request_uri $go_submit_canary { + default fastapi; + ~^/api/exams/submit(\?|$) $go_submit_cohort; +} + +map $go_submit_canary $submit_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.submit-canary-25pct.conf b/docker/nginx.submit-canary-25pct.conf new file mode 100644 index 0000000..ad6f161 --- /dev/null +++ b/docker/nginx.submit-canary-25pct.conf @@ -0,0 +1,14 @@ +split_clients "$http_authorization" $go_submit_cohort { + 25% go; + * fastapi; +} + +map $request_uri $go_submit_canary { + default fastapi; + ~^/api/exams/submit(\?|$) $go_submit_cohort; +} + +map $go_submit_canary $submit_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.submit-canary-50pct.conf b/docker/nginx.submit-canary-50pct.conf new file mode 100644 index 0000000..9019bc7 --- /dev/null +++ b/docker/nginx.submit-canary-50pct.conf @@ -0,0 +1,14 @@ +split_clients "$http_authorization" $go_submit_cohort { + 50% go; + * fastapi; +} + +map $request_uri $go_submit_canary { + default fastapi; + ~^/api/exams/submit(\?|$) $go_submit_cohort; +} + +map $go_submit_canary $submit_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.submit-canary-5pct.conf b/docker/nginx.submit-canary-5pct.conf new file mode 100644 index 0000000..c3d827f --- /dev/null +++ b/docker/nginx.submit-canary-5pct.conf @@ -0,0 +1,14 @@ +split_clients "$http_authorization" $go_submit_cohort { + 5% go; + * fastapi; +} + +map $request_uri $go_submit_canary { + default fastapi; + ~^/api/exams/submit(\?|$) $go_submit_cohort; +} + +map $go_submit_canary $submit_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.submit-canary-75pct.conf b/docker/nginx.submit-canary-75pct.conf new file mode 100644 index 0000000..a687425 --- /dev/null +++ b/docker/nginx.submit-canary-75pct.conf @@ -0,0 +1,14 @@ +split_clients "$http_authorization" $go_submit_cohort { + 75% go; + * fastapi; +} + +map $request_uri $go_submit_canary { + default fastapi; + ~^/api/exams/submit(\?|$) $go_submit_cohort; +} + +map $go_submit_canary $submit_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/docker/nginx.submit-canary-off.conf b/docker/nginx.submit-canary-off.conf new file mode 100644 index 0000000..e614c8d --- /dev/null +++ b/docker/nginx.submit-canary-off.conf @@ -0,0 +1,8 @@ +map $request_uri $go_submit_canary { + default fastapi; +} + +map $go_submit_canary $submit_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/go/cmd/server/main.go b/go/cmd/server/main.go index 285c27f..8c367ad 100644 --- a/go/cmd/server/main.go +++ b/go/cmd/server/main.go @@ -9,12 +9,14 @@ import ( "siab1/internal/persistence" ) +var revision = "unknown" + func main() { cfg := config.Load() store := persistence.Connect(cfg.DatabaseURL, cfg.RedisURL) h := httpserver.New(cfg, store) addr := ":" + cfg.Port - log.Printf("siab1 listening on %s runtime=go", addr) + log.Printf("siab1 listening on %s runtime=go revision=%s", addr, revision) if err := http.ListenAndServe(addr, h); err != nil { log.Fatal(err) } diff --git a/go/go.mod b/go/go.mod index 92553e3..cad9448 100644 --- a/go/go.mod +++ b/go/go.mod @@ -4,10 +4,13 @@ go 1.25.0 require ( github.com/jackc/pgx/v5 v5.9.2 + github.com/redis/go-redis/v9 v9.17.2 golang.org/x/crypto v0.52.0 ) require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/go/go.sum b/go/go.sum index d85374e..5ba793e 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,6 +1,14 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -11,6 +19,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= +github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/go/internal/auth/jwt.go b/go/internal/auth/jwt.go index ef3de45..4f7a15b 100644 --- a/go/internal/auth/jwt.go +++ b/go/internal/auth/jwt.go @@ -26,8 +26,29 @@ type Claims struct { FullName string `json:"full_name,omitempty"` StudentClass string `json:"student_class,omitempty"` JobTitle string `json:"job_title,omitempty"` - IsActive bool `json:"is_active,omitempty"` + IsActive bool `json:"is_active"` Exp int64 `json:"exp"` + isActiveSet bool +} + +func (c *Claims) UnmarshalJSON(data []byte) error { + type claimsAlias Claims + var decoded claimsAlias + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + *c = Claims(decoded) + _, c.isActiveSet = fields["is_active"] + return nil +} + +// Active matches FastAPI's hot-path rule: old tokens without is_active remain active. +func (c Claims) Active() bool { + return !c.isActiveSet || c.IsActive } func (c Claims) UserID() (int, error) { @@ -71,7 +92,10 @@ func Parse(secret, token string) (*Claims, error) { if len(parts) != 3 { return nil, ErrBadToken } - if signHS256(secret, parts[0]+"."+parts[1]) != parts[2] { + if !validHeader(parts[0]) || !hmac.Equal( + []byte(signHS256(secret, parts[0]+"."+parts[1])), + []byte(parts[2]), + ) { return nil, ErrBadToken } raw, err := base64.RawURLEncoding.DecodeString(parts[1]) @@ -103,7 +127,10 @@ func ParseAllowExpired(secret, token string) (*Claims, error) { if len(parts) != 3 { return nil, ErrBadToken } - if signHS256(secret, parts[0]+"."+parts[1]) != parts[2] { + if !validHeader(parts[0]) || !hmac.Equal( + []byte(signHS256(secret, parts[0]+"."+parts[1])), + []byte(parts[2]), + ) { return nil, ErrBadToken } raw, err := base64.RawURLEncoding.DecodeString(parts[1]) @@ -147,6 +174,17 @@ func signHS256(secret, data string) string { return b64(mac.Sum(nil)) } +func validHeader(encoded string) bool { + raw, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return false + } + var header struct { + Algorithm string `json:"alg"` + } + return json.Unmarshal(raw, &header) == nil && header.Algorithm == "HS256" +} + func b64(v []byte) string { return base64.RawURLEncoding.EncodeToString(v) } @@ -203,10 +241,16 @@ func SignPayload(secret string, payload any) (string, error) { } func SessionPollToken(secret string, sessionID, userID int) (string, error) { - return SignPayload(secret, map[string]any{ - "sub": fmt.Sprintf("%d", userID), - "sid": sessionID, - "typ": "session_poll", - "exp": time.Now().UTC().Add(15 * time.Minute).Unix(), - }) + payload := struct { + Sub string `json:"sub"` + SID int `json:"sid"` + Type string `json:"typ"` + Expires int64 `json:"exp"` + }{ + Sub: fmt.Sprintf("%d", userID), + SID: sessionID, + Type: "session_poll", + Expires: time.Now().UTC().Add(15 * time.Minute).Unix(), + } + return SignPayload(secret, payload) } diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 94656c7..0160eb2 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -24,6 +24,13 @@ type Config struct { SEBStrictMode bool SEBDefaultConfigKey string SEBDefaultBrowserExamKey string + SEBChallengeEnabled bool + SEBChallengeRedisPrefix string + StartDBAdmissionLimit int + MonitoringDeltaEnabled bool + MonitoringDeltaMaxLen int + MonitoringDeltaTTL int + SIABReplica string } func Load() Config { @@ -45,9 +52,24 @@ func Load() Config { SEBStrictMode: truthy(getenv("SEB_STRICT_MODE", "true")), SEBDefaultConfigKey: getenv("SEB_DEFAULT_CONFIG_KEY", "default-seb-config-key"), SEBDefaultBrowserExamKey: getenv("SEB_DEFAULT_BROWSER_EXAM_KEY", "default-browser-exam-key"), + SEBChallengeEnabled: truthy(getenv("SEB_CHALLENGE_ENABLED", "true")), + SEBChallengeRedisPrefix: getenv("SEB_CHALLENGE_REDIS_PREFIX", "seb:challenge:"), + StartDBAdmissionLimit: positiveInt(getenv("START_DB_ADMISSION_LIMIT", "4"), 4), + MonitoringDeltaEnabled: truthy(getenv("MONITORING_DELTA_STREAM_ENABLED", "true")), + MonitoringDeltaMaxLen: positiveInt(getenv("MONITORING_DELTA_STREAM_MAX_LEN", "5000"), 5000), + MonitoringDeltaTTL: positiveInt(getenv("MONITORING_DELTA_STREAM_TTL_SECONDS", "7200"), 7200), + SIABReplica: strings.TrimSpace(os.Getenv("SIAB_REPLICA")), } } +func positiveInt(raw string, fallback int) int { + value, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || value <= 0 { + return fallback + } + return value +} + func getenv(key, fallback string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v diff --git a/go/internal/exam/answer_native.go b/go/internal/exam/answer_native.go new file mode 100644 index 0000000..c5d5dd9 --- /dev/null +++ b/go/internal/exam/answer_native.go @@ -0,0 +1,492 @@ +package exam + +import ( + "context" + "encoding/json" + "errors" + "log" + "net/http" + "strconv" + "strings" + "time" + + "siab1/internal/auth" + "siab1/internal/persistence" +) + +type answerRepository interface { + startSecurityRepository + HasPool() bool + HasRedis() bool + ProbeAnswerSession(context.Context, int, int) (*persistence.AnswerSessionProbe, error) + LoadAnswerQuestion(context.Context, int, int) (*persistence.AnswerQuestionPayload, error) + WriteSingleAnswerDirect(context.Context, int, int, int, persistence.AnswerWriteFields) (string, error) + AllowSlidingRate(context.Context, string, string, int, int) (bool, int) + AddAnsweredQuestions(context.Context, int, []int) (int, bool, error) + PatchSessionAnsweredCount(context.Context, int, int, int) error + ReplaceSessionAnswerCache(context.Context, int, any) error +} + +type answerHTTPError struct { + Status int + Detail any + Headers map[string]string +} + +func (e *answerHTTPError) Error() string { return "answer http error" } + +func answerError(status int, detail any) *answerHTTPError { + return &answerHTTPError{Status: status, Detail: detail} +} + +type answerService struct { + repo answerRepository + secret string + defaultSEBKey string + challengeEnabled bool + challengePrefix string + disableRateLimit bool + examPeak bool +} + +type nativeAnswerResponse struct { + Status string `json:"status"` + QuestionID int `json:"question_id"` + Message string `json:"message"` +} + +type answerSubmit struct { + SessionID int + QuestionID int + SelectedOptionID *int + SelectedOptionIDs []int + AnswerText *string + StatementAnswers map[string]bool + Metadata map[string]any +} + +func (d deps) submitAnswer(w http.ResponseWriter, r *http.Request) { + if d.store == nil || !d.store.HasPool() { + writeDetail(w, http.StatusServiceUnavailable, "Database tidak tersedia") + return + } + response, err := answerService{ + repo: d.store, + secret: d.secret, + defaultSEBKey: d.sebKey, + challengeEnabled: d.sebChallenge, + challengePrefix: d.sebChallengePrefix, + disableRateLimit: d.disableRateLimit, + examPeak: d.examPeak, + }.accept(r) + if err != nil { + log.Printf("go_answer outcome=failure status=%d", err.Status) + if errors.Is(r.Context().Err(), context.Canceled) { + return + } + if err.Status == http.StatusUnauthorized { + w.Header().Set("WWW-Authenticate", "Bearer") + } + for key, value := range err.Headers { + w.Header().Set(key, value) + } + writeJSON(w, err.Status, map[string]any{"detail": err.Detail}) + return + } + log.Printf("go_answer outcome=success question_id=%d", response.QuestionID) + writeJSON(w, http.StatusOK, response) +} + +func alreadySubmittedAnswer(questionID int) *nativeAnswerResponse { + return &nativeAnswerResponse{ + Status: "saved", + QuestionID: questionID, + Message: "Sesi ujian sudah dikumpulkan. Jawaban tambahan diabaikan.", + } +} + +func (s answerService) accept(r *http.Request) (*nativeAnswerResponse, *answerHTTPError) { + userID, active, err := s.authenticate(r) + if err != nil { + return nil, err + } + if !active { + return nil, answerError(http.StatusForbidden, "Akun tidak aktif") + } + body, err := readAnswerSubmit(r) + if err != nil { + return nil, err + } + if !s.disableRateLimit { + key := strconv.Itoa(userID) + ":" + strconv.Itoa(body.SessionID) + allowed, remaining := s.repo.AllowSlidingRate(r.Context(), "answer_submit", key, 60, 60) + if !allowed { + return nil, &answerHTTPError{ + Status: http.StatusTooManyRequests, + Detail: "Terlalu banyak request. Tunggu beberapa saat.", + Headers: map[string]string{ + "Retry-After": "5", + "X-RateLimit-Remaining": strconv.Itoa(remaining), + }, + } + } + } + probe, probeErr := s.repo.ProbeAnswerSession(r.Context(), body.SessionID, userID) + if probeErr != nil { + if persistence.IsTransientDB(probeErr) { + return nil, busyAnswer() + } + return nil, answerError(http.StatusInternalServerError, "Gagal memuat sesi") + } + if probe == nil { + return nil, answerError(http.StatusNotFound, "Sesi ujian tidak ditemukan") + } + status := strings.ToLower(strings.TrimSpace(probe.Status)) + if status == "submitted" || status == "completed" { + return alreadySubmittedAnswer(body.QuestionID), nil + } + if status != "in_progress" { + return nil, answerError(http.StatusBadRequest, "Sesi ujian sudah berakhir") + } + settings, settingsErr := s.repo.LoadStartSecuritySettings(r.Context()) + if settingsErr != nil { + return nil, answerError(http.StatusInternalServerError, "Internal Server Error") + } + if sebErr := validateStartSEB( + r.Context(), s.repo, r, probe.ExamID, settings, s.defaultSEBKey, s.challengeEnabled, s.challengePrefix, + ); sebErr != nil { + return nil, answerError(sebErr.Status, sebErr.Detail) + } + question, questionErr := s.repo.LoadAnswerQuestion(r.Context(), probe.ExamID, body.QuestionID) + if questionErr != nil { + if persistence.IsTransientDB(questionErr) { + return nil, busyAnswer() + } + return nil, answerError(http.StatusInternalServerError, "Gagal memuat soal") + } + if question == nil { + return nil, answerError(http.StatusNotFound, "Soal tidak ditemukan") + } + metadata := mergeAnswerMetadata(body.Metadata, body.StatementAnswers) + isCorrect, points := validateAnswerPayload(question, body) + writeAt := time.Now().UTC() + outcome, writeErr := s.repo.WriteSingleAnswerDirect(r.Context(), probe.ID, userID, question.ID, persistence.AnswerWriteFields{ + SelectedOptionID: body.SelectedOptionID, + SelectedOptionIDs: int32s(body.SelectedOptionIDs), + AnswerText: body.AnswerText, + Metadata: persistence.MetadataJSON(metadata), + IsCorrect: isCorrect, + PointsEarned: points, + AnsweredAt: writeAt, + }) + if writeErr != nil { + if persistence.IsAnswerNotFound(writeErr) { + return nil, answerError(http.StatusNotFound, "Sesi ujian tidak ditemukan") + } + if persistence.IsAnswerEnded(writeErr) { + return nil, answerError(http.StatusBadRequest, "Sesi ujian sudah berakhir") + } + if persistence.IsTransientDB(writeErr) { + return nil, busyAnswer() + } + log.Printf("go_answer upsert failed question=%d err=%v", question.ID, writeErr) + return nil, answerError(http.StatusConflict, "Konflik penyimpanan jawaban, silakan coba lagi") + } + if outcome == "submitted" { + return alreadySubmittedAnswer(question.ID), nil + } + s.afterWrite(r.Context(), probe.ID, userID, question.ID) + return &nativeAnswerResponse{ + Status: "saved", + QuestionID: question.ID, + Message: "Jawaban berhasil disimpan", + }, nil +} + +func (s answerService) afterWrite(ctx context.Context, sessionID, userID, questionID int) { + if count, ok, err := s.repo.AddAnsweredQuestions(ctx, sessionID, []int{questionID}); err == nil && ok { + _ = s.repo.PatchSessionAnsweredCount(ctx, sessionID, userID, count) + } + _ = s.repo.ReplaceSessionAnswerCache(ctx, sessionID, map[string]bool{strconv.Itoa(questionID): true}) +} + +func (s answerService) authenticate(r *http.Request) (int, bool, *answerHTTPError) { + raw := auth.Bearer(r.Header.Get("Authorization")) + if raw == "" { + return 0, false, answerError(http.StatusUnauthorized, "Not authenticated") + } + claims, err := auth.Parse(s.secret, raw) + if err != nil { + return 0, false, answerError(http.StatusUnauthorized, "Token tidak valid atau sudah kadaluarsa") + } + userID, err := claims.UserID() + if err != nil { + return 0, false, answerError(http.StatusUnauthorized, "Token tidak valid atau sudah kadaluarsa") + } + return userID, claims.Active(), nil +} + +func busyAnswer() *answerHTTPError { + return &answerHTTPError{ + Status: http.StatusServiceUnavailable, + Detail: "Server sedang sibuk, silakan ulangi kirim jawaban.", + Headers: map[string]string{"Retry-After": "1"}, + } +} + +func readAnswerSubmit(r *http.Request) (*answerSubmit, *answerHTTPError) { + var raw map[string]any + if err := readJSON(r, &raw); err != nil { + return nil, answerError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + sessionID, ok := coerceSubmitInt(raw["session_id"]) + if !ok { + return nil, answerError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + questionID, ok := coerceSubmitInt(raw["question_id"]) + if !ok { + return nil, answerError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + out := &answerSubmit{SessionID: sessionID, QuestionID: questionID, Metadata: map[string]any{}} + if value, exists := raw["selected_option_id"]; exists { + if value == nil || value == "" || value == "null" || value == "undefined" { + out.SelectedOptionID = nil + } else if id, ok := coerceSubmitInt(value); ok { + out.SelectedOptionID = &id + } else { + return nil, answerError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + } + if value, exists := raw["selected_option_ids"]; exists && value != nil && value != "" && value != "null" { + list, ok := value.([]any) + if !ok { + return nil, answerError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + for _, item := range list { + if item == nil { + continue + } + id, ok := coerceSubmitInt(item) + if !ok { + return nil, answerError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + out.SelectedOptionIDs = append(out.SelectedOptionIDs, id) + } + } + if value, exists := raw["answer_text"]; exists && value != nil { + text := strings.TrimSpace(stringify(value)) + out.AnswerText = &text + } + if value, exists := raw["statement_answers"]; exists && value != nil { + obj, ok := value.(map[string]any) + if !ok { + return nil, answerError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + out.StatementAnswers = map[string]bool{} + for key, item := range obj { + flag, _ := coerceBool(item) + out.StatementAnswers[key] = flag + } + } + if value, exists := raw["answer_metadata"]; exists && value != nil { + obj, ok := value.(map[string]any) + if !ok { + return nil, answerError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + out.Metadata = obj + } + return out, nil +} + +func coerceSubmitInt(v any) (int, bool) { + switch typed := v.(type) { + case json.Number: + n, err := typed.Int64() + return int(n), err == nil + case float64: + return int(typed), true + case int: + return typed, true + case string: + n, err := strconv.Atoi(strings.TrimSpace(typed)) + return n, err == nil + default: + return 0, false + } +} + +func stringify(v any) string { + switch typed := v.(type) { + case string: + return typed + default: + b, _ := json.Marshal(typed) + return string(b) + } +} + +func mergeAnswerMetadata(incoming map[string]any, statements map[string]bool) map[string]any { + final := map[string]any{} + for key, value := range incoming { + final[key] = value + } + delete(final, "replace_statement_answers") + delete(final, "delete_statement_answers") + replace, _ := coerceBool(incoming["replace_statement_answers"]) + deleteStmts, _ := coerceBool(incoming["delete_statement_answers"]) + var merged map[string]bool + if deleteStmts { + merged = map[string]bool{} + } else if statements == nil { + if replace { + merged = map[string]bool{} + } + } else if replace { + merged = statements + } else { + merged = statements + } + if merged != nil { + if len(merged) > 0 { + final["statement_answers"] = merged + } else { + delete(final, "statement_answers") + } + } + return final +} + +func validateAnswerPayload(question *persistence.AnswerQuestionPayload, body *answerSubmit) (*bool, *float64) { + settings := map[string]any{} + _ = json.Unmarshal(question.QuestionSettings, &settings) + correctIDs := map[int]struct{}{} + for _, opt := range question.Options { + if opt.IsCorrect { + correctIDs[opt.ID] = struct{}{} + } + } + points := question.Points + switch question.QuestionType { + case "multiple_choice", "true_false": + if body.SelectedOptionID == nil { + return boolPtr(false), floatPtr(0) + } + if _, ok := correctIDs[*body.SelectedOptionID]; ok { + return boolPtr(true), floatPtr(points) + } + return boolPtr(false), floatPtr(0) + case "multiple_choice_complex": + pgk := "checkbox" + if question.PGKType != nil && strings.TrimSpace(*question.PGKType) != "" { + pgk = strings.TrimSpace(*question.PGKType) + } else if raw, ok := settings["pgk_type"].(string); ok && strings.TrimSpace(raw) != "" { + pgk = strings.TrimSpace(raw) + } + if pgk == "table_validation" { + return validateTable(settings, body.StatementAnswers, points) + } + selected := map[int]struct{}{} + for _, id := range body.SelectedOptionIDs { + selected[id] = struct{}{} + } + if len(selected) == 0 || len(correctIDs) == 0 { + return boolPtr(false), floatPtr(0) + } + if ok, _ := coerceBool(settings["partial_scoring"]); ok { + correctCount := 0 + incorrectCount := 0 + for id := range selected { + if _, ok := correctIDs[id]; ok { + correctCount++ + } else { + incorrectCount++ + } + } + ratio := float64(correctCount-incorrectCount) / float64(len(correctIDs)) + if ratio < 0 { + ratio = 0 + } + return boolPtr(ratio >= 0.5), floatPtr(points * ratio) + } + if len(selected) != len(correctIDs) { + return boolPtr(false), floatPtr(0) + } + for id := range selected { + if _, ok := correctIDs[id]; !ok { + return boolPtr(false), floatPtr(0) + } + } + return boolPtr(true), floatPtr(points) + case "essay": + return nil, nil + case "short_answer": + text := "" + if body.AnswerText != nil { + text = strings.TrimSpace(*body.AnswerText) + } + if manual, _ := coerceBool(settings["require_manual_grading"]); text == "" || manual { + return nil, nil + } + rawAcceptable, _ := settings["acceptable_answers"].([]any) + if len(rawAcceptable) == 0 { + return nil, nil + } + caseSensitive, _ := coerceBool(settings["case_sensitive"]) + if !caseSensitive { + text = strings.ToLower(text) + } + for _, item := range rawAcceptable { + candidate := strings.TrimSpace(stringify(item)) + if !caseSensitive { + candidate = strings.ToLower(candidate) + } + if text == candidate { + return boolPtr(true), floatPtr(points) + } + } + return boolPtr(false), floatPtr(0) + default: + return boolPtr(false), floatPtr(0) + } +} + +func validateTable(settings map[string]any, statements map[string]bool, points float64) (*bool, *float64) { + correct := map[string]*bool{} + if list, ok := settings["statement_answers"].([]any); ok && len(list) > 0 { + for idx, value := range list { + v, _ := coerceBool(value) + correct[strconv.Itoa(idx)] = &v + } + } else if obj, ok := settings["correct_statements"].(map[string]any); ok && len(obj) > 0 { + for key, value := range obj { + v, _ := coerceBool(value) + correct[key] = &v + } + } + if len(correct) == 0 { + return boolPtr(false), floatPtr(0) + } + correctCount := 0 + for key, expected := range correct { + if expected == nil { + continue + } + got, ok := statements[key] + if ok && got == *expected { + correctCount++ + } + } + ratio := float64(correctCount) / float64(len(correct)) + return boolPtr(ratio == 1.0), floatPtr(points * ratio) +} + +func int32s(ids []int) []int32 { + if len(ids) == 0 { + return nil + } + out := make([]int32, 0, len(ids)) + for _, id := range ids { + out = append(out, int32(id)) + } + return out +} diff --git a/go/internal/exam/answer_native_test.go b/go/internal/exam/answer_native_test.go new file mode 100644 index 0000000..5840136 --- /dev/null +++ b/go/internal/exam/answer_native_test.go @@ -0,0 +1,275 @@ +package exam + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "siab1/internal/auth" + "siab1/internal/persistence" +) + +const answerSecret = "answer-test-secret" + +type fakeAnswerRepo struct { + mu sync.Mutex + sessions map[int]persistence.AnswerSessionProbe + owners map[int]int + questions map[int]*persistence.AnswerQuestionPayload + writes []persistence.AnswerWriteFields + writeN int + writeErr error + redisErr error + failDB bool +} + +func (f *fakeAnswerRepo) HasPool() bool { return true } +func (f *fakeAnswerRepo) HasRedis() bool { return true } + +func (f *fakeAnswerRepo) ProbeAnswerSession(_ context.Context, sessionID, userID int) (*persistence.AnswerSessionProbe, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.failDB { + return nil, errors.New("timeout") + } + row, ok := f.sessions[sessionID] + if !ok || f.owners[sessionID] != userID { + return nil, nil + } + copyRow := row + return ©Row, nil +} + +func (f *fakeAnswerRepo) LoadAnswerQuestion(_ context.Context, examID, questionID int) (*persistence.AnswerQuestionPayload, error) { + f.mu.Lock() + defer f.mu.Unlock() + q := f.questions[questionID] + if q == nil || q.ExamID != examID { + return nil, nil + } + copyQ := *q + return ©Q, nil +} + +func (f *fakeAnswerRepo) WriteSingleAnswerDirect(_ context.Context, sessionID, userID, _ int, fields persistence.AnswerWriteFields) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.writeErr != nil { + return "", f.writeErr + } + row, ok := f.sessions[sessionID] + if !ok || f.owners[sessionID] != userID { + return "", errors.New("answer session not found") + } + status := row.Status + if status == "submitted" || status == "completed" { + return "submitted", nil + } + if status != "in_progress" { + return status, errors.New("answer session ended") + } + f.writes = append(f.writes, fields) + f.writeN++ + return "in_progress", nil +} + +func (f *fakeAnswerRepo) AllowSlidingRate(context.Context, string, string, int, int) (bool, int) { + return true, 60 +} + +func (f *fakeAnswerRepo) AddAnsweredQuestions(context.Context, int, []int) (int, bool, error) { + if f.redisErr != nil { + return 0, false, f.redisErr + } + return 1, true, nil +} + +func (f *fakeAnswerRepo) PatchSessionAnsweredCount(context.Context, int, int, int) error { + return f.redisErr +} + +func (f *fakeAnswerRepo) ReplaceSessionAnswerCache(context.Context, int, any) error { + return f.redisErr +} + +func (f *fakeAnswerRepo) LoadStartSecuritySettings(context.Context) (persistence.StartSecuritySettings, error) { + return persistence.StartSecuritySettings{DeveloperMode: true, AllowMobileApps: true}, nil +} + +func (f *fakeAnswerRepo) StartSEBKeys(context.Context, int) (string, string, bool, error) { + return "seb", "", true, nil +} + +func (f *fakeAnswerRepo) RedisGet(context.Context, string) (string, bool, error) { + return "", false, nil +} + +func (f *fakeAnswerRepo) RedisSet(context.Context, string, string, time.Duration) error { + return nil +} + +func (f *fakeAnswerRepo) RedisDelete(context.Context, string) error { return nil } + +func mcQuestion() *persistence.AnswerQuestionPayload { + return &persistence.AnswerQuestionPayload{ + ID: 11, ExamID: 5, QuestionType: "multiple_choice", Points: 1, + QuestionSettings: []byte("{}"), + Options: []persistence.AnswerQuestionOption{{ID: 21, IsCorrect: true}, {ID: 22, IsCorrect: false}}, + } +} + +func answerToken(t *testing.T, userID int, active bool) string { + t.Helper() + tok, err := auth.SignUser(answerSecret, userID, "s", "student", "S", "XII", active) + if err != nil { + t.Fatal(err) + } + return tok +} + +func doAnswer(t *testing.T, repo *fakeAnswerRepo, token, body string) *httptest.ResponseRecorder { + t.Helper() + svc := answerService{repo: repo, secret: answerSecret, disableRateLimit: true, examPeak: true} + req := httptest.NewRequest(http.MethodPost, "/api/exams/submit-answer", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + handler := deps{store: &persistence.Store{}}.submitAnswer + _ = handler + response, err := svc.accept(req) + if err != nil { + rec.WriteHeader(err.Status) + _ = json.NewEncoder(rec).Encode(map[string]any{"detail": err.Detail}) + return rec + } + rec.WriteHeader(http.StatusOK) + _ = json.NewEncoder(rec).Encode(response) + return rec +} + +func liveRepo() *fakeAnswerRepo { + return &fakeAnswerRepo{ + sessions: map[int]persistence.AnswerSessionProbe{9: {ID: 9, ExamID: 5, Status: "in_progress"}}, + owners: map[int]int{9: 3}, + questions: map[int]*persistence.AnswerQuestionPayload{11: mcQuestion()}, + } +} + +func TestAnswerFirstAndUpdate(t *testing.T) { + repo := liveRepo() + tok := answerToken(t, 3, true) + first := doAnswer(t, repo, tok, `{"session_id":9,"question_id":11,"selected_option_id":21}`) + if first.Code != 200 { + t.Fatalf("first %s", first.Body.String()) + } + second := doAnswer(t, repo, tok, `{"session_id":9,"question_id":11,"selected_option_id":22}`) + if second.Code != 200 || repo.writeN != 2 { + t.Fatalf("update writes=%d body=%s", repo.writeN, second.Body.String()) + } +} + +func TestAnswerIdempotentAndInvalid(t *testing.T) { + repo := liveRepo() + tok := answerToken(t, 3, true) + body := `{"session_id":9,"question_id":11,"selected_option_id":21}` + if doAnswer(t, repo, tok, body).Code != 200 || doAnswer(t, repo, tok, body).Code != 200 { + t.Fatal("idempotent") + } + if doAnswer(t, repo, tok, `{"session_id":9,"question_id":99,"selected_option_id":21}`).Code != 404 { + t.Fatal("invalid question") + } + if doAnswer(t, repo, tok, `{"session_id":8,"question_id":11,"selected_option_id":21}`).Code != 404 { + t.Fatal("invalid session") + } +} + +func TestAnswerOwnershipSubmittedExpired(t *testing.T) { + repo := liveRepo() + repo.sessions[10] = persistence.AnswerSessionProbe{ID: 10, ExamID: 5, Status: "in_progress"} + repo.owners[10] = 4 + repo.sessions[11] = persistence.AnswerSessionProbe{ID: 11, ExamID: 5, Status: "submitted"} + repo.owners[11] = 3 + repo.sessions[12] = persistence.AnswerSessionProbe{ID: 12, ExamID: 5, Status: "paused"} + repo.owners[12] = 3 + tok := answerToken(t, 3, true) + if doAnswer(t, repo, tok, `{"session_id":10,"question_id":11,"selected_option_id":21}`).Code != 404 { + t.Fatal("ownership") + } + submitted := doAnswer(t, repo, tok, `{"session_id":11,"question_id":11,"selected_option_id":21}`) + if submitted.Code != 200 || !bytes.Contains(submitted.Body.Bytes(), []byte("sudah dikumpulkan")) { + t.Fatalf("submitted %s", submitted.Body.String()) + } + if doAnswer(t, repo, tok, `{"session_id":12,"question_id":11,"selected_option_id":21}`).Code != 400 { + t.Fatal("expired") + } +} + +func TestAnswerTypesConcurrentAndFailures(t *testing.T) { + repo := liveRepo() + settings, _ := json.Marshal(map[string]any{ + "pgk_type": "table_validation", "statement_answers": []any{true, false}, + }) + pgk := "table_validation" + repo.questions[12] = &persistence.AnswerQuestionPayload{ + ID: 12, ExamID: 5, QuestionType: "multiple_choice_complex", PGKType: &pgk, Points: 2, QuestionSettings: settings, + } + repo.questions[13] = &persistence.AnswerQuestionPayload{ + ID: 13, ExamID: 5, QuestionType: "essay", Points: 5, QuestionSettings: []byte("{}"), + } + tok := answerToken(t, 3, true) + table := doAnswer(t, repo, tok, `{"session_id":9,"question_id":12,"statement_answers":{"0":true,"1":false}}`) + essay := doAnswer(t, repo, tok, `{"session_id":9,"question_id":13,"answer_text":"jawaban"}`) + if table.Code != 200 || essay.Code != 200 { + t.Fatalf("types %s %s", table.Body.String(), essay.Body.String()) + } + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + doAnswer(t, repo, tok, `{"session_id":9,"question_id":11,"selected_option_id":21}`) + }() + } + wg.Wait() + repo.writeErr = errors.New("deadlock") + conflict := doAnswer(t, repo, tok, `{"session_id":9,"question_id":11,"selected_option_id":21}`) + if conflict.Code != 409 { + t.Fatalf("db fail %d", conflict.Code) + } + repo.writeErr = nil + repo.redisErr = errors.New("redis down") + if doAnswer(t, repo, tok, `{"session_id":9,"question_id":11,"selected_option_id":21}`).Code != 200 { + t.Fatal("redis fail should not lose answer") + } + repo.failDB = true + busy := doAnswer(t, repo, tok, `{"session_id":9,"question_id":11,"selected_option_id":21}`) + if busy.Code != 503 { + t.Fatalf("busy %d", busy.Code) + } +} + +func TestAnswerAuthMalformed(t *testing.T) { + repo := liveRepo() + if doAnswer(t, repo, "", `{"session_id":9,"question_id":11,"selected_option_id":21}`).Code != 401 { + t.Fatal("missing auth") + } + if doAnswer(t, repo, "bad", `{"session_id":9,"question_id":11}`).Code != 401 { + t.Fatal("invalid auth") + } + tok := answerToken(t, 3, true) + if doAnswer(t, repo, tok, `{`).Code != 422 { + t.Fatal("malformed") + } + inactive := answerToken(t, 3, false) + if doAnswer(t, repo, inactive, `{"session_id":9,"question_id":11,"selected_option_id":21}`).Code != 403 { + t.Fatal("inactive") + } +} diff --git a/go/internal/exam/autosave_native.go b/go/internal/exam/autosave_native.go new file mode 100644 index 0000000..ce8a9b9 --- /dev/null +++ b/go/internal/exam/autosave_native.go @@ -0,0 +1,355 @@ +package exam + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "log" + "net/http" + "strconv" + "strings" + "time" + + "siab1/internal/auth" + "siab1/internal/persistence" +) + +type autosaveHTTPError struct { + Status int + Detail any +} + +func (e *autosaveHTTPError) Error() string { return "autosave http error" } + +func autosaveError(status int, detail any) *autosaveHTTPError { + return &autosaveHTTPError{Status: status, Detail: detail} +} + +func (d deps) autoSave(w http.ResponseWriter, r *http.Request) { + if d.store == nil || !d.store.HasPool() { + writeDetail(w, http.StatusServiceUnavailable, "Database tidak tersedia") + return + } + response, err := d.acceptLegacyAutosave(r) + if err != nil { + if errors.Is(r.Context().Err(), context.Canceled) { + return + } + if err.Status == http.StatusUnauthorized { + w.Header().Set("WWW-Authenticate", "Bearer") + } + writeJSON(w, err.Status, map[string]any{"detail": err.Detail}) + return + } + writeJSON(w, http.StatusOK, response) +} + +func (d deps) autoSaveBatch(w http.ResponseWriter, r *http.Request) { + if d.store == nil || !d.store.HasPool() { + writeDetail(w, http.StatusServiceUnavailable, "Database tidak tersedia") + return + } + response, err := d.acceptAutosaveBatch(r) + if err != nil { + if errors.Is(r.Context().Err(), context.Canceled) { + return + } + if err.Status == http.StatusUnauthorized { + w.Header().Set("WWW-Authenticate", "Bearer") + } + writeJSON(w, err.Status, map[string]any{"detail": err.Detail}) + return + } + writeJSON(w, http.StatusOK, response) +} + +func (d deps) acceptLegacyAutosave(r *http.Request) (map[string]any, *autosaveHTTPError) { + userID, active, err := authenticateAutosave(d.secret, r) + if err != nil { + return nil, err + } + if !active { + return nil, autosaveError(http.StatusForbidden, "Akun tidak aktif") + } + var body map[string]any + if readErr := readJSON(r, &body); readErr != nil { + return nil, autosaveError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + sessionID, ok := coerceSubmitInt(body["session_id"]) + if !ok { + return nil, autosaveError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + if _, hasTS := body["timestamp"]; !hasTS { + return nil, autosaveError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + rawAnswers, exists := body["answers"] + if !exists { + return nil, autosaveError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + answers, ok := rawAnswers.(map[string]any) + if !ok { + return nil, autosaveError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + normalized := map[string]any{} + for key, value := range answers { + id, convErr := strconv.Atoi(strings.TrimSpace(key)) + if convErr != nil { + return nil, autosaveError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + normalized[strconv.Itoa(id)] = value + } + probe, probeErr := d.store.ProbeAnswerSession(r.Context(), sessionID, userID) + if probeErr != nil { + if persistence.IsTransientDB(probeErr) { + return nil, autosaveError(http.StatusServiceUnavailable, "Server sedang sibuk, silakan ulangi kirim jawaban.") + } + return nil, autosaveError(http.StatusInternalServerError, "Gagal memuat sesi") + } + if probe == nil || strings.ToLower(strings.TrimSpace(probe.Status)) != "in_progress" { + return nil, autosaveError(http.StatusNotFound, "Sesi ujian tidak ditemukan atau sudah berakhir") + } + if cacheErr := d.store.ReplaceSessionAnswerCache(r.Context(), sessionID, normalized); cacheErr != nil { + log.Printf("go_autosave redis failed session=%d err=%v", sessionID, cacheErr) + return nil, autosaveError(http.StatusInternalServerError, "Internal Server Error") + } + ids := make([]int, 0, len(normalized)) + for key := range normalized { + if id, convErr := strconv.Atoi(key); convErr == nil { + ids = append(ids, id) + } + } + if count, ok, runtimeErr := d.store.AddAnsweredQuestions(r.Context(), sessionID, ids); runtimeErr == nil && ok { + _ = d.store.PatchSessionAnsweredCount(r.Context(), sessionID, userID, count) + } + return map[string]any{ + "status": "success", + "saved_count": len(normalized), + "timestamp": time.Now().UTC().Format("2006-01-02T15:04:05.000000+00:00"), + }, nil +} + +func (d deps) acceptAutosaveBatch(r *http.Request) (map[string]any, *autosaveHTTPError) { + userID, active, err := authenticateAutosave(d.secret, r) + if err != nil { + return nil, err + } + if !active { + return nil, autosaveError(http.StatusForbidden, "Akun tidak aktif") + } + var body map[string]any + if readErr := readJSON(r, &body); readErr != nil { + return nil, autosaveError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + sessionID, ok := coerceSubmitInt(body["session_id"]) + if !ok { + return nil, autosaveError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + rawAnswers, exists := body["answers"] + if !exists { + return nil, autosaveError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + list, ok := rawAnswers.([]any) + if !ok { + return nil, autosaveError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + probe, probeErr := d.store.ProbeAnswerSession(r.Context(), sessionID, userID) + if probeErr != nil { + if persistence.IsTransientDB(probeErr) { + return nil, autosaveError(http.StatusServiceUnavailable, "Server sedang sibuk, silakan ulangi kirim jawaban.") + } + return nil, autosaveError(http.StatusInternalServerError, "Gagal memuat sesi") + } + if probe == nil || strings.ToLower(strings.TrimSpace(probe.Status)) != "in_progress" { + return nil, autosaveError(http.StatusNotFound, "Sesi ujian tidak ditemukan atau sudah berakhir") + } + if len(list) == 0 { + return map[string]any{ + "status": "no_changes", + "queued_count": 0, + "queue_id": "empty", + "timestamp": time.Now().UTC().Format("2006-01-02T15:04:05.000000+00:00"), + }, nil + } + deduped := map[int]map[string]any{} + order := make([]int, 0) + for _, raw := range list { + item, ok := raw.(map[string]any) + if !ok { + return nil, autosaveError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + qid, ok := coerceSubmitInt(item["question_id"]) + if !ok { + return nil, autosaveError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + if _, seen := deduped[qid]; !seen { + order = append(order, qid) + } + deduped[qid] = item + } + questionIDs := make([]int, 0, len(order)) + questionIDs = append(questionIDs, order...) + validIDs, validErr := d.store.ValidQuestionIDs(r.Context(), probe.ExamID, questionIDs) + if validErr != nil { + if persistence.IsTransientDB(validErr) { + return nil, autosaveError(http.StatusServiceUnavailable, "Server sedang sibuk, silakan ulangi kirim jawaban.") + } + return nil, autosaveError(http.StatusInternalServerError, "Gagal memuat soal") + } + writes := make([]persistence.BatchAnswerWrite, 0) + validQuestionIDs := make([]int, 0) + cacheFlags := map[string]bool{} + for _, qid := range order { + if _, ok := validIDs[qid]; !ok { + continue + } + item := deduped[qid] + write, parseErr := parseBatchWrite(item, qid) + if parseErr != nil { + return nil, parseErr + } + writes = append(writes, write) + validQuestionIDs = append(validQuestionIDs, qid) + cacheFlags[strconv.Itoa(qid)] = true + } + now := time.Now().UTC() + outcome, writeErr := d.store.WriteBatchAutosave(r.Context(), probe.ID, userID, writes, now) + if writeErr != nil { + if persistence.IsTransientDB(writeErr) { + return nil, autosaveError(http.StatusServiceUnavailable, "Server sedang sibuk, silakan ulangi kirim jawaban.") + } + log.Printf("go_autosave_batch write failed session=%d err=%v", probe.ID, writeErr) + return nil, autosaveError(http.StatusConflict, "Konflik penyimpanan jawaban, silakan coba lagi") + } + if outcome.Status == "not_found" { + return nil, autosaveError(http.StatusNotFound, "Sesi ujian tidak ditemukan") + } + if outcome.Status == "ended" { + return nil, autosaveError(http.StatusBadRequest, "Sesi ujian sudah berakhir") + } + if cacheErr := d.store.ReplaceSessionAnswerCache(r.Context(), probe.ID, cacheFlags); cacheErr != nil { + log.Printf("go_autosave_batch redis failed session=%d err=%v", probe.ID, cacheErr) + return nil, autosaveError(http.StatusInternalServerError, "Internal Server Error") + } + if count, ok, runtimeErr := d.store.AddAnsweredQuestions(r.Context(), probe.ID, validQuestionIDs); runtimeErr == nil && ok { + _ = d.store.PatchSessionAnsweredCount(r.Context(), probe.ID, userID, count) + } + status := "no_changes" + if outcome.Changed > 0 { + status = "saved_to_db" + } + return map[string]any{ + "status": status, + "queued_count": len(writes), + "queue_id": shortQueueID(), + "timestamp": now.Format("2006-01-02T15:04:05.000000+00:00"), + }, nil +} + +func parseBatchWrite(item map[string]any, qid int) (persistence.BatchAnswerWrite, *autosaveHTTPError) { + out := persistence.BatchAnswerWrite{QuestionID: qid} + if value, exists := item["selected_option_id"]; exists { + if value == nil || value == "" || value == "null" || value == "undefined" { + out.SelectedOptionID = nil + } else if id, ok := coerceSubmitInt(value); ok { + out.SelectedOptionID = &id + } else { + return out, autosaveError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + } + if value, exists := item["selected_option_ids"]; exists { + out.HasOptionIDs = true + if value != nil && value != "" && value != "null" { + list, ok := value.([]any) + if !ok { + return out, autosaveError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + for _, raw := range list { + if raw == nil { + continue + } + id, ok := coerceSubmitInt(raw) + if !ok { + return out, autosaveError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + out.SelectedOptionIDs = append(out.SelectedOptionIDs, int32(id)) + } + if out.SelectedOptionIDs == nil { + out.SelectedOptionIDs = []int32{} + } + } + } + if value, exists := item["answer_text"]; exists && value != nil { + text := stringify(value) + out.AnswerText = &text + } + if value, exists := item["answer_metadata"]; exists && value != nil { + obj, ok := value.(map[string]any) + if !ok { + return out, autosaveError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + out.IncomingMetadata = obj + } + if value, exists := item["statement_answers"]; exists && value != nil { + obj, ok := value.(map[string]any) + if !ok { + return out, autosaveError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + out.HasStatements = true + out.StatementAnswers = map[string]bool{} + for key, raw := range obj { + flag, _ := coerceBool(raw) + out.StatementAnswers[key] = flag + } + } + return out, nil +} + +func authenticateAutosave(secret string, r *http.Request) (int, bool, *autosaveHTTPError) { + raw := auth.Bearer(r.Header.Get("Authorization")) + if raw == "" { + return 0, false, autosaveError(http.StatusUnauthorized, "Not authenticated") + } + claims, err := auth.Parse(secret, raw) + if err != nil { + return 0, false, autosaveError(http.StatusUnauthorized, "Token tidak valid atau sudah kadaluarsa") + } + userID, err := claims.UserID() + if err != nil { + return 0, false, autosaveError(http.StatusUnauthorized, "Token tidak valid atau sudah kadaluarsa") + } + return userID, claims.Active(), nil +} + +func pythonBool(v any) bool { + switch typed := v.(type) { + case nil: + return false + case bool: + return typed + case string: + return typed != "" + case float64: + return typed != 0 + case int: + return typed != 0 + case json.Number: + n, _ := typed.Float64() + return n != 0 + case map[string]any: + return len(typed) > 0 + case []any: + return len(typed) > 0 + default: + return true + } +} + +func shortQueueID() string { + buf := make([]byte, 4) + if _, err := rand.Read(buf); err != nil { + return strconv.FormatInt(time.Now().UnixNano(), 16)[:8] + } + return hex.EncodeToString(buf) +} diff --git a/go/internal/exam/http.go b/go/internal/exam/http.go index cab9b0a..3e969f9 100644 --- a/go/internal/exam/http.go +++ b/go/internal/exam/http.go @@ -13,35 +13,52 @@ import ( ) type deps struct { - store *persistence.Store - secret string - appSecret string - examPeak bool - fallback http.Handler - sebLegacy bool - sebStrict bool - sebKey string - sebBEK string - baseURL string + store *persistence.Store + secret string + appSecret string + examPeak bool + fallback http.Handler + sebLegacy bool + sebStrict bool + sebKey string + sebBEK string + baseURL string + enforceSXB bool + sebChallenge bool + sebChallengePrefix string + startGate *startAdmission + monitoringDelta bool + monitoringDeltaMaxLen int + monitoringDeltaTTL int + disableRateLimit bool } func Register(mux *http.ServeMux, store *persistence.Store, cfg config.Config, fallback http.Handler) { d := deps{ - store: store, - secret: cfg.JWTSecretKey, - appSecret: cfg.SecretKey, - examPeak: cfg.ExamPeakMode, - fallback: fallback, - sebLegacy: cfg.SEBDesktopLegacy, - sebStrict: cfg.SEBStrictMode, - sebKey: cfg.SEBDefaultConfigKey, - sebBEK: cfg.SEBDefaultBrowserExamKey, - baseURL: cfg.BaseURL, + store: store, + secret: cfg.JWTSecretKey, + appSecret: cfg.SecretKey, + examPeak: cfg.ExamPeakMode, + fallback: fallback, + sebLegacy: cfg.SEBDesktopLegacy, + sebStrict: cfg.SEBStrictMode, + sebKey: cfg.SEBDefaultConfigKey, + sebBEK: cfg.SEBDefaultBrowserExamKey, + baseURL: cfg.BaseURL, + enforceSXB: cfg.EnforceSXB, + sebChallenge: cfg.SEBChallengeEnabled, + sebChallengePrefix: cfg.SEBChallengeRedisPrefix, + startGate: newStartAdmission(cfg.StartDBAdmissionLimit), + monitoringDelta: cfg.MonitoringDeltaEnabled, + monitoringDeltaMaxLen: cfg.MonitoringDeltaMaxLen, + monitoringDeltaTTL: cfg.MonitoringDeltaTTL, + disableRateLimit: cfg.DisableRateLimit, } mux.HandleFunc("POST /api/exams/auto-save", d.autoSave) mux.HandleFunc("POST /api/exams/submit-answer", d.submitAnswer) mux.HandleFunc("GET /api/exams/session/{session_id}/answers", d.getAnswers) mux.HandleFunc("POST /api/exams/{exam_id}/start", d.startExam) + mux.HandleFunc("GET /internal/start-admission", d.startAdmissionStatus) mux.HandleFunc("GET /api/exams/session/{session_id}/remaining-time", d.remainingTime) mux.HandleFunc("GET /api/auth/me", d.me) mux.HandleFunc("GET /api/runtime/policy", d.runtimePolicy) @@ -160,13 +177,9 @@ func registerLoginLane(mux *http.ServeMux, lane string, h http.HandlerFunc) { mux.HandleFunc("POST /api/"+lane+"/auth/signin", h) } -func (d deps) autoSave(w http.ResponseWriter, r *http.Request) { - d.proxyExamWrite(w, r) -} -func (d deps) submitAnswer(w http.ResponseWriter, r *http.Request) { - d.proxyExamWrite(w, r) -} + + func (d deps) getAnswers(w http.ResponseWriter, r *http.Request) { userID, ok := d.userOrFallback(w, r) diff --git a/go/internal/exam/join.go b/go/internal/exam/join.go index 7d62918..9b47dfa 100644 --- a/go/internal/exam/join.go +++ b/go/internal/exam/join.go @@ -14,87 +14,9 @@ import ( var ( joinMu sync.Mutex joinHits = map[string][]time.Time{} - joinLimit = 5 + joinLimit = 10 ) -func (d deps) joinExam(w http.ResponseWriter, r *http.Request) { - userID, ok := d.userOrFallback(w, r) - if !ok { - return - } - claims, err := auth.Parse(d.secret, auth.Bearer(r.Header.Get("Authorization"))) - if err != nil { - writeDetail(w, http.StatusUnauthorized, auth.FormatDetail(err)) - return - } - if claims.Role != "student" && claims.Role != "guruplus" { - writeDetail(w, http.StatusForbidden, "Hanya peserta ujian yang dapat mengikuti ujian") - return - } - var body struct { - Token string `json:"token"` - } - if err := readJSON(r, &body); err != nil { - writeDetail(w, http.StatusUnprocessableEntity, "Payload tidak valid") - return - } - key := itoa(userID) + ":" + clientIP(r) - if !allowJoin(key) { - w.Header().Set("Retry-After", "60") - writeDetail(w, http.StatusTooManyRequests, "Terlalu banyak percobaan token salah. Tunggu 1 menit.") - return - } - token := strings.ToUpper(strings.TrimSpace(body.Token)) - if len(token) != 6 { - writeDetail(w, http.StatusBadRequest, "Token harus 6 karakter") - return - } - ex, err := d.store.GetExamByToken(r.Context(), token) - if err != nil { - writeDetail(w, http.StatusInternalServerError, "Gagal memuat ujian") - return - } - if ex == nil { - writeDetail(w, http.StatusNotFound, "Token ujian tidak valid") - return - } - if !ex.Published { - writeDetail(w, http.StatusForbidden, "Ujian belum dipublikasikan") - return - } - now := time.Now().UTC() - if now.Before(ex.StartTime.UTC()) { - writeDetail(w, http.StatusForbidden, "Ujian belum dimulai") - return - } - if now.After(ex.EndTime.UTC()) { - writeDetail(w, http.StatusForbidden, "Ujian sudah berakhir") - return - } - if ok, detail := participantAccess(ex, userID, claims.Role, claims.StudentClass); !ok { - writeDetail(w, http.StatusForbidden, detail) - return - } - done, err := d.store.CompletedAttemptCount(r.Context(), userID, ex.ID) - if err != nil { - writeDetail(w, http.StatusInternalServerError, "Gagal memeriksa percobaan") - return - } - if done >= ex.MaxAttempts { - writeDetail(w, http.StatusForbidden, "Anda sudah menggunakan semua kesempatan ("+itoa(ex.MaxAttempts)+"x)") - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "exam_id": ex.ID, - "title": ex.Title, - "description": ex.Description, - "duration_minutes": ex.DurationMinutes, - "question_count": ex.QuestionCount, - "allowed": true, - "message": "Token valid. Anda dapat memulai ujian.", - }) -} - func (d deps) myResults(w http.ResponseWriter, r *http.Request) { userID, ok := d.userOrFallback(w, r) if !ok { diff --git a/go/internal/exam/join_native.go b/go/internal/exam/join_native.go new file mode 100644 index 0000000..c107cc1 --- /dev/null +++ b/go/internal/exam/join_native.go @@ -0,0 +1,183 @@ +package exam + +import ( + "context" + "errors" + "log" + "net/http" + "strings" + "time" + + "siab1/internal/auth" + "siab1/internal/persistence" +) + +type joinRepository interface { + LookupJoinUser(context.Context, int) (*persistence.JoinUserRow, error) + LookupJoinExamByToken(context.Context, string) (*persistence.JoinExamRow, error) + CompletedAttemptCount(context.Context, int, int) (int, error) + CountJoinQuestions(context.Context, int) (int, error) +} + +type joinHTTPError struct { + Status int + Detail any +} + +func (e *joinHTTPError) Error() string { + return "join http error" +} + +func joinError(status int, detail any) *joinHTTPError { + return &joinHTTPError{Status: status, Detail: detail} +} + +type joinService struct { + repo joinRepository + secret string +} + +type nativeJoinResponse struct { + ExamID int `json:"exam_id"` + Title string `json:"title"` + Description *string `json:"description"` + DurationMinutes int `json:"duration_minutes"` + QuestionCount int `json:"question_count"` + Allowed bool `json:"allowed"` + Message string `json:"message"` +} + +func (d deps) joinExam(w http.ResponseWriter, r *http.Request) { + if d.store == nil || !d.store.HasPool() { + writeDetail(w, http.StatusServiceUnavailable, "Database tidak tersedia") + return + } + response, joinErr := joinService{repo: d.store, secret: d.secret}.join(r) + if joinErr != nil { + log.Printf("go_join outcome=failure status=%d", joinErr.Status) + if errors.Is(r.Context().Err(), context.Canceled) { + return + } + if joinErr.Status == http.StatusUnauthorized { + w.Header().Set("WWW-Authenticate", "Bearer") + } + if joinErr.Status == http.StatusTooManyRequests { + w.Header().Set("Retry-After", "60") + } + writeJSON(w, joinErr.Status, map[string]any{"detail": joinErr.Detail}) + return + } + log.Printf("go_join outcome=success exam_id=%d", response.ExamID) + writeJSON(w, http.StatusOK, response) +} + +func (s joinService) join(r *http.Request) (*nativeJoinResponse, *joinHTTPError) { + ctx := r.Context() + user, err := s.authenticate(r) + if err != nil { + return nil, err + } + token, err := readJoinToken(r) + if err != nil { + return nil, err + } + if !allowJoin(itoa(user.ID) + ":" + clientIP(r)) { + return nil, joinError( + http.StatusTooManyRequests, + "Terlalu banyak percobaan token salah. Tunggu 1 menit.", + ) + } + role := strings.ToLower(strings.TrimSpace(user.Role)) + if role != "student" && role != "guruplus" { + return nil, joinError(http.StatusForbidden, "Hanya peserta ujian yang dapat mengikuti ujian") + } + if len(token) != 6 { + return nil, joinError(http.StatusBadRequest, "Token harus 6 karakter") + } + exam, lookupErr := s.repo.LookupJoinExamByToken(ctx, token) + if lookupErr != nil { + return nil, joinError(http.StatusInternalServerError, "Gagal memuat ujian") + } + if exam == nil { + return nil, joinError(http.StatusNotFound, "Token ujian tidak valid") + } + if !exam.Published { + return nil, joinError(http.StatusForbidden, "Ujian belum dipublikasikan") + } + now := time.Now().UTC() + if now.Before(exam.StartTime.UTC()) { + return nil, joinError(http.StatusForbidden, "Ujian belum dimulai") + } + if now.After(exam.EndTime.UTC()) { + return nil, joinError(http.StatusForbidden, "Ujian sudah berakhir") + } + className := "" + if user.StudentClass != nil { + className = *user.StudentClass + } + if ok, detail := participantAccess(exam.AccessRow(), user.ID, role, className); !ok { + return nil, joinError(http.StatusForbidden, detail) + } + done, countErr := s.repo.CompletedAttemptCount(ctx, user.ID, exam.ID) + if countErr != nil { + return nil, joinError(http.StatusInternalServerError, "Gagal memeriksa percobaan") + } + if done >= exam.MaxAttempts { + return nil, joinError( + http.StatusForbidden, + "Anda sudah menggunakan semua kesempatan ("+itoa(exam.MaxAttempts)+"x)", + ) + } + questions, questionErr := s.repo.CountJoinQuestions(ctx, exam.ID) + if questionErr != nil { + return nil, joinError(http.StatusInternalServerError, "Gagal memuat ujian") + } + return &nativeJoinResponse{ + ExamID: exam.ID, + Title: exam.Title, + Description: exam.Description, + DurationMinutes: exam.DurationMinutes, + QuestionCount: questions, + Allowed: true, + Message: "Token valid. Anda dapat memulai ujian.", + }, nil +} + +func (s joinService) authenticate(r *http.Request) (*persistence.JoinUserRow, *joinHTTPError) { + raw := auth.Bearer(r.Header.Get("Authorization")) + if raw == "" { + return nil, joinError(http.StatusUnauthorized, "Not authenticated") + } + claims, err := auth.Parse(s.secret, raw) + if err != nil { + return nil, joinError(http.StatusUnauthorized, "Token tidak valid atau sudah kadaluarsa") + } + userID, err := claims.UserID() + if err != nil { + return nil, joinError(http.StatusUnauthorized, "Token tidak valid atau sudah kadaluarsa") + } + user, lookupErr := s.repo.LookupJoinUser(r.Context(), userID) + if lookupErr != nil { + return nil, joinError(http.StatusInternalServerError, "Gagal memuat profil") + } + if user == nil { + return nil, joinError(http.StatusUnauthorized, "Token tidak valid atau sudah kadaluarsa") + } + if !user.IsActive { + return nil, joinError(http.StatusForbidden, "Akun tidak aktif") + } + return user, nil +} + +func readJoinToken(r *http.Request) (string, *joinHTTPError) { + var body struct { + Token *string `json:"token"` + } + if err := readJSON(r, &body); err != nil { + return "", joinError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + if body.Token == nil { + return "", joinError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + return strings.ToUpper(strings.TrimSpace(*body.Token)), nil +} diff --git a/go/internal/exam/join_native_test.go b/go/internal/exam/join_native_test.go new file mode 100644 index 0000000..e31633e --- /dev/null +++ b/go/internal/exam/join_native_test.go @@ -0,0 +1,393 @@ +package exam + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "siab1/internal/auth" + "siab1/internal/persistence" +) + +const joinSecret = "join-test-secret" + +type fakeJoinRepo struct { + mu sync.Mutex + users map[int]*persistence.JoinUserRow + exam *persistence.JoinExamRow + attempts map[int]int + questions int + lookups int + counts int + usersGets int +} + +func (f *fakeJoinRepo) LookupJoinUser(_ context.Context, id int) (*persistence.JoinUserRow, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.usersGets++ + if f.users == nil { + return nil, nil + } + user := f.users[id] + if user == nil { + return nil, nil + } + copyUser := *user + return ©User, nil +} + +func (f *fakeJoinRepo) LookupJoinExamByToken(_ context.Context, token string) (*persistence.JoinExamRow, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.lookups++ + if f.exam == nil || token != "ABC123" { + return nil, nil + } + copyExam := *f.exam + return ©Exam, nil +} + +func (f *fakeJoinRepo) CompletedAttemptCount(_ context.Context, userID, _ int) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.attempts == nil { + return 0, nil + } + return f.attempts[userID], nil +} + +func (f *fakeJoinRepo) CountJoinQuestions(context.Context, int) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.counts++ + return f.questions, nil +} + +func activeExam() *persistence.JoinExamRow { + now := time.Now().UTC() + desc := "Join desc" + role := "teacher" + return &persistence.JoinExamRow{ + ID: 7, + Title: "Ujian Join", + Description: &desc, + DurationMinutes: 90, + StartTime: now.Add(-time.Hour), + EndTime: now.Add(2 * time.Hour), + Published: true, + MaxAttempts: 3, + CreatorRole: &role, + } +} + +func studentUser(id int, class string, active bool) *persistence.JoinUserRow { + cls := class + return &persistence.JoinUserRow{ + ID: id, + Role: "student", + StudentClass: &cls, + IsActive: active, + } +} + +func joinToken(user *persistence.JoinUserRow) string { + className := "" + if user.StudentClass != nil { + className = *user.StudentClass + } + tok, err := auth.SignUser(joinSecret, user.ID, "s"+itoa(user.ID), user.Role, "Student", className, user.IsActive) + if err != nil { + panic(err) + } + return tok +} + +func doJoin(t *testing.T, repo *fakeJoinRepo, user *persistence.JoinUserRow, body string, authHeader string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/exams/join", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + if authHeader != "" { + req.Header.Set("Authorization", authHeader) + } else if user != nil { + req.Header.Set("Authorization", "Bearer "+joinToken(user)) + } + req.RemoteAddr = "10.0.0." + itoa(userIDOr(user)) + ":1234" + rec := httptest.NewRecorder() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response, joinErr := joinService{repo: repo, secret: joinSecret}.join(r) + if joinErr != nil { + if joinErr.Status == http.StatusUnauthorized { + w.Header().Set("WWW-Authenticate", "Bearer") + } + if joinErr.Status == http.StatusTooManyRequests { + w.Header().Set("Retry-After", "60") + } + writeJSON(w, joinErr.Status, map[string]any{"detail": joinErr.Detail}) + return + } + writeJSON(w, http.StatusOK, response) + }) + handler.ServeHTTP(rec, req) + return rec +} + +func userIDOr(user *persistence.JoinUserRow) int { + if user == nil { + return 1 + } + return user.ID +} + +func decodeJoin(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("json: %v body=%s", err, rec.Body.String()) + } + return payload +} + +func TestJoinValid(t *testing.T) { + user := studentUser(11, "XII A", true) + repo := &fakeJoinRepo{users: map[int]*persistence.JoinUserRow{11: user}, exam: activeExam(), questions: 4} + rec := doJoin(t, repo, user, `{"token":"abc123"}`, "") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + payload := decodeJoin(t, rec) + if payload["exam_id"] != float64(7) || payload["allowed"] != true || payload["question_count"] != float64(4) { + t.Fatalf("payload=%v", payload) + } + if payload["message"] != "Token valid. Anda dapat memulai ujian." { + t.Fatalf("message=%v", payload["message"]) + } + if repo.lookups != 1 || repo.counts != 1 || repo.usersGets != 1 { + t.Fatalf("sql lookups=%d counts=%d users=%d", repo.lookups, repo.counts, repo.usersGets) + } +} + +func TestJoinInvalidToken(t *testing.T) { + user := studentUser(12, "XII A", true) + repo := &fakeJoinRepo{users: map[int]*persistence.JoinUserRow{12: user}, exam: activeExam()} + rec := doJoin(t, repo, user, `{"token":"ZZZZZZ"}`, "") + if rec.Code != http.StatusNotFound || decodeJoin(t, rec)["detail"] != "Token ujian tidak valid" { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestJoinUnpublished(t *testing.T) { + user := studentUser(13, "XII A", true) + exam := activeExam() + exam.Published = false + repo := &fakeJoinRepo{users: map[int]*persistence.JoinUserRow{13: user}, exam: exam} + rec := doJoin(t, repo, user, `{"token":"ABC123"}`, "") + if rec.Code != http.StatusForbidden || decodeJoin(t, rec)["detail"] != "Ujian belum dipublikasikan" { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestJoinBeforeStart(t *testing.T) { + user := studentUser(14, "XII A", true) + exam := activeExam() + exam.StartTime = time.Now().UTC().Add(time.Hour) + repo := &fakeJoinRepo{users: map[int]*persistence.JoinUserRow{14: user}, exam: exam} + rec := doJoin(t, repo, user, `{"token":"ABC123"}`, "") + if rec.Code != http.StatusForbidden || decodeJoin(t, rec)["detail"] != "Ujian belum dimulai" { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestJoinEnded(t *testing.T) { + user := studentUser(15, "XII A", true) + exam := activeExam() + exam.EndTime = time.Now().UTC().Add(-time.Minute) + repo := &fakeJoinRepo{users: map[int]*persistence.JoinUserRow{15: user}, exam: exam} + rec := doJoin(t, repo, user, `{"token":"ABC123"}`, "") + if rec.Code != http.StatusForbidden || decodeJoin(t, rec)["detail"] != "Ujian sudah berakhir" { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestJoinInactiveUser(t *testing.T) { + user := studentUser(16, "XII A", false) + repo := &fakeJoinRepo{users: map[int]*persistence.JoinUserRow{16: user}, exam: activeExam()} + rec := doJoin(t, repo, user, `{"token":"ABC123"}`, "") + if rec.Code != http.StatusForbidden || decodeJoin(t, rec)["detail"] != "Akun tidak aktif" { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestJoinMissingAndInvalidAuth(t *testing.T) { + user := studentUser(17, "XII A", true) + repo := &fakeJoinRepo{users: map[int]*persistence.JoinUserRow{17: user}, exam: activeExam()} + missing := doJoin(t, repo, user, `{"token":"ABC123"}`, " ") + if missing.Code != http.StatusUnauthorized || decodeJoin(t, missing)["detail"] != "Not authenticated" { + t.Fatalf("missing status=%d body=%s", missing.Code, missing.Body.String()) + } + bad := doJoin(t, repo, user, `{"token":"ABC123"}`, "Bearer not-a-jwt") + if bad.Code != http.StatusUnauthorized || decodeJoin(t, bad)["detail"] != "Token tidak valid atau sudah kadaluarsa" { + t.Fatalf("bad status=%d body=%s", bad.Code, bad.Body.String()) + } + if bad.Header().Get("WWW-Authenticate") != "Bearer" { + t.Fatal("missing www-authenticate") + } +} + +func TestJoinAllowedAndForbiddenClass(t *testing.T) { + allowed := studentUser(18, "XII A", true) + denied := studentUser(19, "XII B", true) + exam := activeExam() + classes := "XII A" + exam.AllowedClasses = &classes + repo := &fakeJoinRepo{ + users: map[int]*persistence.JoinUserRow{18: allowed, 19: denied}, + exam: exam, + } + ok := doJoin(t, repo, allowed, `{"token":"ABC123"}`, "") + if ok.Code != http.StatusOK { + t.Fatalf("allowed status=%d body=%s", ok.Code, ok.Body.String()) + } + no := doJoin(t, repo, denied, `{"token":"ABC123"}`, "") + if no.Code != http.StatusForbidden { + t.Fatalf("denied status=%d body=%s", no.Code, no.Body.String()) + } + detail, _ := decodeJoin(t, no)["detail"].(string) + if detail == "" || !bytes.Contains([]byte(detail), []byte("Kelas Anda")) { + t.Fatalf("detail=%q", detail) + } +} + +func TestJoinAllowedAndForbiddenStudent(t *testing.T) { + listed := studentUser(20, "XII A", true) + other := studentUser(21, "XII A", true) + exam := activeExam() + students := "20" + exam.AllowedStudents = &students + repo := &fakeJoinRepo{ + users: map[int]*persistence.JoinUserRow{20: listed, 21: other}, + exam: exam, + } + ok := doJoin(t, repo, listed, `{"token":"ABC123"}`, "") + if ok.Code != http.StatusOK { + t.Fatalf("listed status=%d", ok.Code) + } + no := doJoin(t, repo, other, `{"token":"ABC123"}`, "") + if no.Code != http.StatusForbidden || decodeJoin(t, no)["detail"] != "Anda tidak termasuk peserta yang diizinkan untuk ujian ini" { + t.Fatalf("unlisted status=%d body=%s", no.Code, no.Body.String()) + } +} + +func TestJoinStaffAndGuruPlus(t *testing.T) { + teacher := &persistence.JoinUserRow{ID: 30, Role: "teacher", IsActive: true} + admin := &persistence.JoinUserRow{ID: 31, Role: "admin", IsActive: true} + guru := &persistence.JoinUserRow{ID: 32, Role: "guruplus", StudentClass: strp("GuruPlus"), IsActive: true} + exam := activeExam() + exam.AllowedClasses = strp("GuruPlus") + teacherCreator := "teacher" + exam.CreatorRole = &teacherCreator + repo := &fakeJoinRepo{ + users: map[int]*persistence.JoinUserRow{30: teacher, 31: admin, 32: guru}, + exam: exam, + } + if rec := doJoin(t, repo, teacher, `{"token":"ABC123"}`, ""); rec.Code != http.StatusForbidden { + t.Fatalf("teacher=%d", rec.Code) + } + if rec := doJoin(t, repo, admin, `{"token":"ABC123"}`, ""); rec.Code != http.StatusForbidden { + t.Fatalf("admin=%d", rec.Code) + } + if rec := doJoin(t, repo, guru, `{"token":"ABC123"}`, ""); rec.Code != http.StatusForbidden { + t.Fatalf("guruplus teacher-exam=%d body=%s", rec.Code, rec.Body.String()) + } + dev := "developer" + exam.CreatorRole = &dev + if rec := doJoin(t, repo, guru, `{"token":"ABC123"}`, ""); rec.Code != http.StatusOK { + t.Fatalf("guruplus developer-exam=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestJoinExistingAndRepeated(t *testing.T) { + user := studentUser(22, "XII A", true) + repo := &fakeJoinRepo{ + users: map[int]*persistence.JoinUserRow{22: user}, + exam: activeExam(), + attempts: map[int]int{22: 0}, + questions: 2, + } + first := doJoin(t, repo, user, `{"token":"ABC123"}`, "") + second := doJoin(t, repo, user, `{"token":"ABC123"}`, "") + if first.Code != http.StatusOK || second.Code != http.StatusOK { + t.Fatalf("repeat %d %d", first.Code, second.Code) + } + if decodeJoin(t, first)["exam_id"] != decodeJoin(t, second)["exam_id"] { + t.Fatal("repeat changed exam") + } +} + +func TestJoinMaxAttempts(t *testing.T) { + user := studentUser(23, "XII A", true) + exam := activeExam() + exam.MaxAttempts = 1 + repo := &fakeJoinRepo{ + users: map[int]*persistence.JoinUserRow{23: user}, + exam: exam, + attempts: map[int]int{23: 1}, + } + rec := doJoin(t, repo, user, `{"token":"ABC123"}`, "") + if rec.Code != http.StatusForbidden { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestJoinConcurrent(t *testing.T) { + user := studentUser(24, "XII A", true) + repo := &fakeJoinRepo{users: map[int]*persistence.JoinUserRow{24: user}, exam: activeExam(), questions: 1} + var wg sync.WaitGroup + codes := make([]int, 8) + wg.Add(8) + for i := 0; i < 8; i++ { + go func(i int) { + defer wg.Done() + rec := doJoin(t, repo, user, `{"token":"ABC123"}`, "") + codes[i] = rec.Code + }(i) + } + wg.Wait() + for i, code := range codes { + if code != http.StatusOK { + t.Fatalf("i=%d status=%d", i, code) + } + } +} + +func TestJoinMalformed(t *testing.T) { + user := studentUser(25, "XII A", true) + repo := &fakeJoinRepo{users: map[int]*persistence.JoinUserRow{25: user}, exam: activeExam()} + missing := doJoin(t, repo, user, `{}`, "") + if missing.Code != http.StatusUnprocessableEntity { + t.Fatalf("missing token status=%d", missing.Code) + } + bad := doJoin(t, repo, user, `{`, "") + if bad.Code != http.StatusUnprocessableEntity { + t.Fatalf("bad json status=%d", bad.Code) + } +} + +func TestJoinNoPoolDoesNotProxy(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/exams/join", bytes.NewBufferString(`{"token":"ABC123"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+joinToken(studentUser(26, "XII A", true))) + rec := httptest.NewRecorder() + deps{}.joinExam(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if decodeJoin(t, rec)["detail"] != "Database tidak tersedia" { + t.Fatalf("body=%s", rec.Body.String()) + } +} diff --git a/go/internal/exam/runtime.go b/go/internal/exam/runtime.go index aaae20a..ec5c055 100644 --- a/go/internal/exam/runtime.go +++ b/go/internal/exam/runtime.go @@ -11,9 +11,7 @@ import ( "siab1/internal/persistence" ) -func (d deps) autoSaveBatch(w http.ResponseWriter, r *http.Request) { - d.proxyExamWrite(w, r) -} + func (d deps) journalSync(w http.ResponseWriter, r *http.Request) { d.proxyExamWrite(w, r) diff --git a/go/internal/exam/start.go b/go/internal/exam/start.go index 34b2f8b..ae0b5d1 100644 --- a/go/internal/exam/start.go +++ b/go/internal/exam/start.go @@ -1,19 +1,11 @@ package exam import ( - "crypto/sha256" - "encoding/binary" "net/http" "strconv" "time" - - "siab1/internal/persistence" ) -func (d deps) startExam(w http.ResponseWriter, r *http.Request) { - d.proxyExamWrite(w, r) -} - func (d deps) remainingTime(w http.ResponseWriter, r *http.Request) { userID, ok := d.userOrFallback(w, r) if !ok { @@ -105,31 +97,3 @@ func (d deps) runtimePolicy(w http.ResponseWriter, r *http.Request) { "source": "server_runtime_policy", }) } - -func shuffleQuestions(items []persistence.QuestionRow, seed string) { - rnd := seeded(seed) - for i := len(items) - 1; i > 0; i-- { - j := int(rnd.Uint32() % uint32(i+1)) - items[i], items[j] = items[j], items[i] - } -} - -func shuffleOptions(items []persistence.OptionRow, seed string) { - rnd := seeded(seed) - for i := len(items) - 1; i > 0; i-- { - j := int(rnd.Uint32() % uint32(i+1)) - items[i], items[j] = items[j], items[i] - } -} - -type rng struct{ s uint64 } - -func seeded(seed string) *rng { - sum := sha256.Sum256([]byte(seed)) - return &rng{s: binary.BigEndian.Uint64(sum[:8])} -} - -func (r *rng) Uint32() uint32 { - r.s = r.s*1664525 + 1013904223 - return uint32(r.s >> 32) -} diff --git a/go/internal/exam/start_admission.go b/go/internal/exam/start_admission.go new file mode 100644 index 0000000..a3f18a9 --- /dev/null +++ b/go/internal/exam/start_admission.go @@ -0,0 +1,90 @@ +package exam + +import ( + "context" + "net/http" + "sync" +) + +type startAdmission struct { + limit int + sem chan struct{} + mu sync.Mutex + holders int + waiters int + peakHolders int + peakWaiters int +} + +func newStartAdmission(limit int) *startAdmission { + if limit <= 0 { + limit = 4 + } + return &startAdmission{limit: limit, sem: make(chan struct{}, limit)} +} + +func (a *startAdmission) acquire(ctx context.Context) (func(), error) { + a.mu.Lock() + a.waiters++ + if a.waiters > a.peakWaiters { + a.peakWaiters = a.waiters + } + a.mu.Unlock() + + select { + case a.sem <- struct{}{}: + a.mu.Lock() + a.waiters-- + a.holders++ + if a.holders > a.peakHolders { + a.peakHolders = a.holders + } + a.mu.Unlock() + case <-ctx.Done(): + a.mu.Lock() + a.waiters-- + a.mu.Unlock() + return nil, ctx.Err() + } + + var once sync.Once + return func() { + once.Do(func() { + <-a.sem + a.mu.Lock() + a.holders-- + a.mu.Unlock() + }) + }, nil +} + +type startAdmissionSnapshot struct { + Limit int + Holders int + Waiters int + PeakHolders int + PeakWaiters int +} + +func (a *startAdmission) snapshot() startAdmissionSnapshot { + a.mu.Lock() + defer a.mu.Unlock() + return startAdmissionSnapshot{ + Limit: a.limit, + Holders: a.holders, + Waiters: a.waiters, + PeakHolders: a.peakHolders, + PeakWaiters: a.peakWaiters, + } +} + +func (d deps) startAdmissionStatus(w http.ResponseWriter, _ *http.Request) { + snapshot := d.startGate.snapshot() + writeJSON(w, http.StatusOK, map[string]int{ + "limit": snapshot.Limit, + "holders": snapshot.Holders, + "waiters": snapshot.Waiters, + "peak_holders": snapshot.PeakHolders, + "peak_waiters": snapshot.PeakWaiters, + }) +} diff --git a/go/internal/exam/start_builder.go b/go/internal/exam/start_builder.go new file mode 100644 index 0000000..ec5e862 --- /dev/null +++ b/go/internal/exam/start_builder.go @@ -0,0 +1,273 @@ +package exam + +import ( + "bytes" + "crypto/md5" + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + + "siab1/internal/persistence" +) + +type startOptionResponse struct { + ID int `json:"id"` + OptionText string `json:"option_text"` + OrderIndex int `json:"order_index"` + OptionGroup string `json:"option_group"` + PairID *string `json:"pair_id"` +} + +type startQuestionResponse struct { + ID int `json:"id"` + QuestionText string `json:"question_text"` + Stimulus *string `json:"stimulus"` + QuestionType string `json:"question_type"` + PgkType *string `json:"pgk_type"` + DifficultyLevel string `json:"difficulty_level"` + Category any `json:"category"` + Tags []any `json:"tags"` + QuestionSettings map[string]any `json:"question_settings"` + Points string `json:"points"` + OrderIndex int `json:"order_index"` + ImageURL *string `json:"image_url"` + VideoURL *string `json:"video_url"` + AudioURL *string `json:"audio_url"` + Options []startOptionResponse `json:"options"` +} + +func buildStartQuestions( + rows []persistence.QuestionRow, + examID int, + userID int, + shuffleQuestionsEnabled bool, + shuffleOptionsEnabled bool, + secret string, +) ([]startQuestionResponse, *startHTTPError) { + questions := append([]persistence.QuestionRow(nil), rows...) + sort.SliceStable(questions, func(i, j int) bool { + return questions[i].OrderIndex < questions[j].OrderIndex + }) + if shuffleQuestionsEnabled { + sort.SliceStable(questions, func(i, j int) bool { + left := md5.Sum([]byte(fmt.Sprintf( + "%s_%d_%d_question_%d", secret, userID, examID, questions[i].ID, + ))) + right := md5.Sum([]byte(fmt.Sprintf( + "%s_%d_%d_question_%d", secret, userID, examID, questions[j].ID, + ))) + return bytes.Compare(left[:], right[:]) < 0 + }) + } + + responses := make([]startQuestionResponse, 0, len(questions)) + skipped := 0 + for _, question := range questions { + settings := decodeQuestionSettings(question.Settings) + text := strings.TrimSpace(question.Text) + placeholder := boolSetting(settings, "is_placeholder") + placeholderSource := strings.ToLower(strings.TrimSpace(stringSetting(settings, "placeholder_source"))) + hasImage := question.ImageURL != nil && *question.ImageURL != "" + if text == "" { + if placeholder && hasImage && placeholderSource == "image" { + text = "Perhatikan gambar soal berikut, lalu pilih jawaban yang benar." + } else { + skipped++ + continue + } + } + + pgk := "checkbox" + if question.PgkType != nil && *question.PgkType != "" { + pgk = *question.PgkType + } else if configured := stringSetting(settings, "pgk_type"); configured != "" { + pgk = configured + } + tableValidation := question.Type == "multiple_choice_complex" && pgk == "table_validation" + requiresOptions := !tableValidation && (question.Type == "multiple_choice" || + question.Type == "multiple_choice_complex" || + question.Type == "true_false") + options := append([]persistence.OptionRow(nil), question.Options...) + sort.SliceStable(options, func(i, j int) bool { + return options[i].OrderIndex < options[j].OrderIndex + }) + if requiresOptions && len(options) == 0 { + skipped++ + continue + } + if requiresOptions && shuffleOptionsEnabled && canShuffleStartOptions(settings, hasImage) { + seed := fmt.Sprintf( + "%s_%d_%d_question_%d_options", secret, userID, examID, question.ID, + ) + pythonShuffle(options, seed) + } + optionResponses := make([]startOptionResponse, 0, len(options)) + if requiresOptions { + for _, option := range options { + group := option.OptionGroup + if group == "" { + group = "standard" + } + optionResponses = append(optionResponses, startOptionResponse{ + ID: option.ID, + OptionText: option.Text, + OrderIndex: option.OrderIndex, + OptionGroup: group, + PairID: option.PairID, + }) + } + } + + if tableValidation { + allowed := true + if raw, exists := settings["allow_table_statement_shuffle"]; exists { + allowed = pythonTruthy(raw) + } + settings["allow_table_statement_shuffle"] = allowed + if shuffleOptionsEnabled && allowed { + shuffleTableStatements(settings, hasImage, fmt.Sprintf( + "%s_%d_%d_question_%d_statements", + secret, userID, examID, question.ID, + )) + } + } + + difficulty := "medium" + if question.Difficulty != "" { + difficulty = question.Difficulty + } + responses = append(responses, startQuestionResponse{ + ID: question.ID, + QuestionText: text, + Stimulus: question.Stimulus, + QuestionType: question.Type, + PgkType: question.PgkType, + DifficultyLevel: difficulty, + Category: nil, + Tags: []any{}, + QuestionSettings: settings, + Points: pythonDecimalFromFloat(question.Points), + OrderIndex: question.OrderIndex, + ImageURL: question.ImageURL, + VideoURL: question.VideoURL, + AudioURL: question.AudioURL, + Options: optionResponses, + }) + } + if skipped > 0 { + return nil, startError( + 500, + fmt.Sprintf( + "Gagal memuat %d soal dari ujian. Data ujian tidak lengkap. Silakan hubungi pengawas atau administrator.", + skipped, + ), + ) + } + return responses, nil +} + +func decodeQuestionSettings(raw []byte) map[string]any { + settings := map[string]any{} + if len(raw) > 0 { + _ = json.Unmarshal(raw, &settings) + } + return settings +} + +func canShuffleStartOptions(settings map[string]any, hasImage bool) bool { + if !boolSetting(settings, "is_placeholder") { + return true + } + if hasImage || strings.EqualFold(strings.TrimSpace(stringSetting(settings, "placeholder_source")), "image") { + return false + } + return boolSetting(settings, "allow_placeholder_shuffle") +} + +func shuffleTableStatements(settings map[string]any, hasImage bool, seed string) { + raw, ok := settings["statements"].([]any) + if !ok || len(raw) == 0 || hasImage { + return + } + meaningful := map[string]struct{}{} + for _, statement := range raw { + text := "" + if object, ok := statement.(map[string]any); ok { + value, exists := object["text"] + if exists { + text = strings.TrimSpace(pyString(value)) + } + } else { + text = strings.TrimSpace(pyString(statement)) + } + if text != "" && text != "-" && text != "--" && text != "\u2014" && text != "\u2013" { + meaningful[text] = struct{}{} + } + } + if len(meaningful) < 2 { + return + } + indexed := make([]any, 0, len(raw)) + for index, statement := range raw { + indexed = append(indexed, map[string]any{ + "text": statement, + "original_index": index, + }) + } + pythonShuffle(indexed, seed) + settings["statements"] = indexed +} + +func pythonTruthy(value any) bool { + switch typed := value.(type) { + case nil: + return false + case bool: + return typed + case string: + return typed != "" + case float64: + return typed != 0 + case json.Number: + return typed.String() != "0" && typed.String() != "0.0" + case []any: + return len(typed) > 0 + case map[string]any: + return len(typed) > 0 + default: + return true + } +} + +func boolSetting(settings map[string]any, key string) bool { + value, ok := settings[key] + return ok && pythonTruthy(value) +} + +func stringSetting(settings map[string]any, key string) string { + value, _ := settings[key].(string) + return value +} + +func pyString(value any) string { + if value == nil { + return "None" + } + if typed, ok := value.(bool); ok { + if typed { + return "True" + } + return "False" + } + return fmt.Sprint(value) +} + +func pythonDecimalFromFloat(value float64) string { + formatted := strconv.FormatFloat(value, 'f', -1, 64) + if !strings.Contains(formatted, ".") { + formatted += ".0" + } + return formatted +} diff --git a/go/internal/exam/start_native.go b/go/internal/exam/start_native.go new file mode 100644 index 0000000..71d2b0b --- /dev/null +++ b/go/internal/exam/start_native.go @@ -0,0 +1,715 @@ +package exam + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "siab1/internal/auth" + "siab1/internal/persistence" +) + +const sessionPollTokenExpiresMinutes = 15 + +type startRepository interface { + startSecurityRepository + BeginStart(context.Context) (persistence.StartTransaction, error) + CanonicalActiveStartSession(context.Context, int, int) (*persistence.StartSessionRow, error) + LoadQuestions(context.Context, int) ([]persistence.QuestionRow, error) + RedisSetNX(context.Context, string, string, time.Duration) (bool, error) + RedisDelete(context.Context, string) error + RedisPublish(context.Context, string, string) error + RedisXAdd(context.Context, string, string, int, int) error +} + +type startHTTPError struct { + Status int + Detail any + RedirectHTML string +} + +func (e *startHTTPError) Error() string { + return fmt.Sprint(e.Detail) +} + +func startError(status int, detail any) *startHTTPError { + return &startHTTPError{Status: status, Detail: detail} +} + +type startService struct { + repo startRepository + gate *startAdmission + jwtSecret string + appSecret string + enforceSXB bool + defaultSEBKey string + challengeEnabled bool + challengePrefix string + monitoringDelta bool + monitoringDeltaMaxLen int + monitoringDeltaTTL int +} + +type nativeStartResponse struct { + SessionID int `json:"session_id"` + ExamID int `json:"exam_id"` + ExamTitle string `json:"exam_title"` + DurationMinutes int `json:"duration_minutes"` + QuestionCount int `json:"question_count"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + ServerTime string `json:"server_time"` + ShowResults bool `json:"show_results"` + ShowTeacherName bool `json:"show_teacher_name"` + TeacherName *string `json:"teacher_name"` + Subject *string `json:"subject"` + ExamType *string `json:"exam_type"` + ShuffleQuestions bool `json:"shuffle_questions"` + ShuffleOptions bool `json:"shuffle_options"` + SessionPollToken string `json:"session_poll_token"` + SessionPollTokenExpiresMinutes int `json:"session_poll_token_expires_minutes"` + Questions []startQuestionResponse `json:"questions"` +} + +func (d deps) startExam(w http.ResponseWriter, r *http.Request) { + if d.store == nil || !d.store.HasPool() { + writeDetail(w, http.StatusServiceUnavailable, "Database tidak tersedia") + return + } + examID, err := strconv.Atoi(r.PathValue("exam_id")) + if err != nil || examID <= 0 { + writeDetail(w, http.StatusUnprocessableEntity, "exam_id tidak valid") + return + } + service := startService{ + repo: d.store, + gate: d.startGate, + jwtSecret: d.secret, + appSecret: d.appSecret, + enforceSXB: d.enforceSXB, + defaultSEBKey: d.sebKey, + challengeEnabled: d.sebChallenge, + challengePrefix: d.sebChallengePrefix, + monitoringDelta: d.monitoringDelta, + monitoringDeltaMaxLen: d.monitoringDeltaMaxLen, + monitoringDeltaTTL: d.monitoringDeltaTTL, + } + response, startErr := service.start(r, examID) + if startErr != nil { + log.Printf("go_start outcome=failure exam_id=%d status=%d", examID, startErr.Status) + if errors.Is(r.Context().Err(), context.Canceled) { + return + } + if startErr.RedirectHTML != "" && strings.Contains(r.Header.Get("Accept"), "text/html") { + http.Redirect(w, r, startErr.RedirectHTML, http.StatusSeeOther) + return + } + if startErr.Status == http.StatusUnauthorized { + w.Header().Set("WWW-Authenticate", "Bearer") + } + writeJSON(w, startErr.Status, map[string]any{"detail": startErr.Detail}) + return + } + log.Printf("go_start outcome=success exam_id=%d session_id=%d", examID, response.SessionID) + writeJSON(w, http.StatusOK, response) +} + +func (s startService) start(r *http.Request, examID int) (*nativeStartResponse, *startHTTPError) { + ctx := r.Context() + securitySettings := s.loadSecuritySettings(ctx) + if err := validateStartSXB(r, securitySettings, s.enforceSXB); err != nil { + return nil, err + } + token := auth.Bearer(r.Header.Get("Authorization")) + if token == "" { + return nil, startError(http.StatusForbidden, "Not authenticated") + } + claims, err := auth.Parse(s.jwtSecret, token) + if err != nil { + return nil, startError(http.StatusUnauthorized, "Token tidak valid atau sudah kadaluarsa") + } + if !claims.Active() { + return nil, startError(http.StatusForbidden, "Akun tidak aktif") + } + role := strings.ToLower(strings.TrimSpace(claims.Role)) + if role != "student" && role != "guruplus" { + return nil, startError(http.StatusForbidden, "Hanya peserta ujian yang dapat mengikuti ujian") + } + userID, err := claims.UserID() + if err != nil { + return nil, startError(http.StatusUnauthorized, "Token tidak valid atau sudah kadaluarsa") + } + + securityRelease, acquireErr := s.gate.acquire(ctx) + if acquireErr != nil { + return nil, startError(http.StatusInternalServerError, "Internal Server Error") + } + sebErr := validateStartSEB( + ctx, s.repo, r, examID, securitySettings, s.defaultSEBKey, + s.challengeEnabled, s.challengePrefix, + ) + securityRelease() + if sebErr != nil { + return nil, sebErr + } + + mainRelease, acquireErr := s.gate.acquire(ctx) + if acquireErr != nil { + return nil, startError(http.StatusInternalServerError, "Internal Server Error") + } + exam, session, resumed, now, txErr := s.startTransaction(ctx, r, examID, userID, claims) + mainRelease() + if txErr != nil { + return nil, txErr + } + + existingSnapshot := map[string]any(nil) + if resumed { + raw, found, err := s.repo.RedisGet(ctx, "exam_session:"+strconv.Itoa(session.ID)) + if err != nil { + return nil, startError(http.StatusInternalServerError, "Internal Server Error") + } + if found { + if json.Unmarshal([]byte(raw), &existingSnapshot) != nil || existingSnapshot == nil { + return nil, startError(http.StatusInternalServerError, "Internal Server Error") + } + } + } + snapshot := buildSessionSnapshot(session, exam, userID, existingSnapshot) + if err := s.storeSessionSnapshot(ctx, session.ID, snapshot); err != nil { + return nil, startError(http.StatusInternalServerError, "Internal Server Error") + } + monitorEvent := map[string]any{ + "type": "student_started", + "user_id": userID, + "username": claims.Username, + "session_id": session.ID, + "timestamp": pythonISOTime(now), + } + monitorJSON, _ := json.Marshal(monitorEvent) + if err := s.repo.RedisPublish(ctx, "exam_monitor_"+strconv.Itoa(examID), string(monitorJSON)); err != nil { + return nil, startError(http.StatusInternalServerError, "Internal Server Error") + } + s.publishMonitoringDelta(ctx, examID, monitorEvent) + + questionRows, questionErr := s.loadStartQuestions(ctx, examID) + if questionErr != nil { + return nil, questionErr + } + if len(questionRows) == 0 { + return nil, startError(http.StatusNotFound, "Soal ujian tidak ditemukan") + } + if jsonInt(snapshot["total_questions"]) != len(questionRows) { + snapshot["total_questions"] = len(questionRows) + if err := s.storeSessionSnapshot(ctx, session.ID, snapshot); err != nil { + return nil, startError(http.StatusInternalServerError, "Internal Server Error") + } + } + questions, buildErr := buildStartQuestions( + questionRows, exam.ID, userID, exam.ShuffleQuestions, + exam.ShuffleOptions, s.appSecret, + ) + if buildErr != nil { + return nil, buildErr + } + pollToken, err := auth.SessionPollToken(s.jwtSecret, session.ID, userID) + if err != nil { + return nil, startError(http.StatusInternalServerError, "Internal Server Error") + } + var teacherName *string + if exam.TeacherVisible() { + teacherName = exam.TeacherName + } + serverTime := time.Now().UTC().Truncate(time.Microsecond) + return &nativeStartResponse{ + SessionID: session.ID, + ExamID: exam.ID, + ExamTitle: exam.Title, + DurationMinutes: exam.DurationMinutes, + QuestionCount: len(questions), + StartTime: pythonTime(session.StartTime), + EndTime: pythonTime(session.StartTime.Add(time.Duration(exam.DurationMinutes) * time.Minute)), + ServerTime: pythonTime(serverTime), + ShowResults: exam.ShowResults, + ShowTeacherName: exam.ShowTeacher(), + TeacherName: teacherName, + Subject: exam.Subject, + ExamType: exam.ExamType, + ShuffleQuestions: exam.ShuffleQuestions, + ShuffleOptions: exam.ShuffleOptions, + SessionPollToken: pollToken, + SessionPollTokenExpiresMinutes: sessionPollTokenExpiresMinutes, + Questions: questions, + }, nil +} + +func (s startService) loadSecuritySettings(ctx context.Context) persistence.StartSecuritySettings { + settings, err := s.repo.LoadStartSecuritySettings(ctx) + if err != nil { + return persistence.StartSecuritySettings{AllowMobileApps: true} + } + return settings +} + +func (s startService) publishMonitoringDelta(ctx context.Context, examID int, payload map[string]any) { + if !s.monitoringDelta { + return + } + event, err := json.Marshal(map[string]any{ + "event_type": payload["type"], + "payload": payload, + "ts": pythonISOTime(time.Now().UTC()), + }) + if err != nil { + return + } + _ = s.repo.RedisXAdd( + ctx, + "monitoring:delta:exam:"+strconv.Itoa(examID), + string(event), + s.monitoringDeltaMaxLen, + s.monitoringDeltaTTL, + ) +} + +func (s startService) startTransaction( + ctx context.Context, + r *http.Request, + examID int, + userID int, + claims *auth.Claims, +) (*persistence.StartExamRow, *persistence.StartSessionRow, bool, time.Time, *startHTTPError) { + tx, err := s.repo.BeginStart(ctx) + if err != nil { + return nil, nil, false, time.Time{}, startError(500, "Internal Server Error") + } + finished := false + defer func() { + if !finished { + rollbackStartTransaction(ctx, tx) + } + }() + exam, err := tx.Exam(ctx, examID) + if err != nil { + return nil, nil, false, time.Time{}, startError(500, "Internal Server Error") + } + if exam == nil { + return nil, nil, false, time.Time{}, startError(404, "Ujian tidak ditemukan") + } + if integrityErr := s.ensureOptionIntegrity(ctx, tx, examID); integrityErr != nil { + return nil, nil, false, time.Time{}, integrityErr + } + if !exam.Published { + return nil, nil, false, time.Time{}, startError(400, "Ujian belum dipublikasikan") + } + now := time.Now().UTC().Truncate(time.Microsecond) + if now.Before(exam.StartTime) { + return nil, nil, false, time.Time{}, startError(400, "Ujian belum dimulai") + } + if now.After(exam.EndTime) { + return nil, nil, false, time.Time{}, startError(400, "Ujian sudah berakhir") + } + accessView := &persistence.ExamRow{ + AllowedClasses: exam.AllowedClasses, + AllowedStudents: exam.AllowedStudents, + CreatorRole: exam.CreatorRole, + } + if ok, detail := participantAccess(accessView, userID, claims.Role, claims.StudentClass); !ok { + return nil, nil, false, time.Time{}, startError(403, detail) + } + state, err := tx.SessionState(ctx, userID, examID) + if err != nil { + return nil, nil, false, time.Time{}, startError(500, "Internal Server Error") + } + if state.AttemptCount >= exam.MaxAttempts { + return nil, nil, false, time.Time{}, startError(400, "Batas percobaan sudah tercapai") + } + answerCounts := map[int]int{} + if len(state.Sessions) > 1 { + ids := make([]int, 0, len(state.Sessions)) + for _, candidate := range state.Sessions { + ids = append(ids, candidate.ID) + } + answerCounts, err = tx.AnswerCounts(ctx, ids) + if err != nil { + return nil, nil, false, time.Time{}, startError(500, "Internal Server Error") + } + } + sortStartSessions(state.Sessions, answerCounts) + var selected *persistence.StartSessionRow + resumed := false + for index := range state.Sessions { + if state.Sessions[index].Status == "in_progress" || state.Sessions[index].Status == "active" { + copy := state.Sessions[index] + selected = © + resumed = true + break + } + } + if selected == nil { + type recoveryCandidate struct { + session persistence.StartSessionRow + result recoveryResult + } + candidates := make([]recoveryCandidate, 0, len(state.Sessions)) + for index := range state.Sessions { + candidate := state.Sessions[index] + if candidate.Status != "terminated" && candidate.Status != "kicked" { + continue + } + logs, err := tx.SessionLogs(ctx, candidate.ID, 30) + if err != nil { + return nil, nil, false, time.Time{}, startError(500, "Internal Server Error") + } + recovery := evaluateSessionRecovery( + candidate.Status, candidate.TerminatedByAdmin, + candidate.ViolationCount, logs, + ) + if recovery.Category == "admin_decision" { + return nil, nil, false, time.Time{}, startError( + 409, + "Sesi dihentikan oleh pengawas/admin. Hubungi pengawas untuk membuka kembali sesi.", + ) + } + candidates = append(candidates, recoveryCandidate{session: candidate, result: recovery}) + } + for _, candidate := range candidates { + if !candidate.result.AllowContinue { + continue + } + selected, err = tx.RecoverSession( + ctx, candidate.session, candidate.result.Category, candidate.result.Message, + ) + if err != nil { + return nil, nil, false, time.Time{}, startError(500, "Internal Server Error") + } + resumed = true + break + } + } + if selected == nil { + userAgent := r.Header.Get("User-Agent") + if userAgent == "" { + userAgent = "unknown" + } + client := persistence.StartClientInfo{ + IPAddress: clientIP(r), + UserAgent: userAgent, + SEBDetected: r.Header.Get("X-SafeExamBrowser-ConfigKeyHash") != "", + StartTime: now, + } + selected, err = tx.CreateSessionWithLog(ctx, userID, examID, client, persistence.SessionStartLog{ + IP: client.IPAddress, + SEBDetected: client.SEBDetected, + Title: exam.Title, + Subject: exam.Subject, + ExamType: exam.ExamType, + AllowedClasses: exam.AllowedClasses, + AllowedStudents: exam.AllowedStudents, + ExamStartTime: exam.StartTime, + ExamEndTime: exam.EndTime, + DurationMinutes: exam.DurationMinutes, + }) + if err != nil { + if persistence.IsIntegrityError(err) { + rollbackStartTransaction(ctx, tx) + finished = true + raced, lookupErr := s.repo.CanonicalActiveStartSession(ctx, userID, examID) + if lookupErr != nil || raced == nil { + return nil, nil, false, time.Time{}, startError( + 409, "Konflik saat memulai sesi ujian, silakan coba lagi.", + ) + } + return exam, raced, true, now, nil + } + return nil, nil, false, time.Time{}, startError(500, "Internal Server Error") + } + } + if err := tx.Commit(ctx); err != nil { + return nil, nil, false, time.Time{}, startError(500, "Internal Server Error") + } + finished = true + return exam, selected, resumed, now, nil +} + +func sortStartSessions(sessions []persistence.StartSessionRow, answerCounts map[int]int) { + sort.SliceStable(sessions, func(i, j int) bool { + left, right := sessions[i], sessions[j] + if answerCounts[left.ID] != answerCounts[right.ID] { + return answerCounts[left.ID] > answerCounts[right.ID] + } + if !left.StartTime.Equal(right.StartTime) { + return left.StartTime.After(right.StartTime) + } + return left.ID > right.ID + }) +} + +func rollbackStartTransaction(ctx context.Context, tx persistence.StartTransaction) { + rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second) + defer cancel() + _ = tx.Rollback(rollbackCtx) +} + +func (s startService) ensureOptionIntegrity( + ctx context.Context, + tx persistence.StartTransaction, + examID int, +) *startHTTPError { + cacheKey := "cache:exam-start-validation:v1:" + strconv.Itoa(examID) + if cached, found, err := s.repo.RedisGet(ctx, cacheKey); err == nil && found && cached == "1" { + return nil + } + tokenBytes := make([]byte, 16) + _, _ = rand.Read(tokenBytes) + token := hex.EncodeToString(tokenBytes) + lockKey := cacheKey + ":lock" + locked, redisErr := s.repo.RedisSetNX(ctx, lockKey, token, 15*time.Second) + if redisErr == nil && !locked { + deadline := time.NewTimer(12 * time.Second) + ticker := time.NewTicker(50 * time.Millisecond) + defer deadline.Stop() + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return startError(500, "Internal Server Error") + case <-deadline.C: + return nil + case <-ticker.C: + if cached, found, err := s.repo.RedisGet(ctx, cacheKey); err == nil && found && cached == "1" { + return nil + } + } + } + } + orphans, err := tx.ValidateOptionIntegrity(ctx, examID) + if err != nil { + return startError(500, "Internal Server Error") + } + if len(orphans) > 0 { + return startError(500, fmt.Sprintf( + "Ujian memiliki %d soal pilihan ganda tanpa pilihan jawaban. Tidak bisa dimulai. Silakan hubungi pengawas atau administrator.", + len(orphans), + )) + } + _ = s.repo.RedisSet(ctx, cacheKey, "1", 120*time.Second) + if redisErr == nil && locked { + if current, found, err := s.repo.RedisGet(ctx, lockKey); err == nil && found && current == token { + _ = s.repo.RedisDelete(ctx, lockKey) + } + } + return nil +} + +func buildSessionSnapshot( + session *persistence.StartSessionRow, + exam *persistence.StartExamRow, + userID int, + existing map[string]any, +) map[string]any { + startedAt := pythonISOTime(session.StartTime) + if value, ok := existing["started_at"].(string); ok && value != "" { + startedAt = value + } + snapshot := map[string]any{ + "session_id": session.ID, + "user_id": userID, + "exam_id": exam.ID, + "start_time": pythonISOTime(session.StartTime), + "end_time": nil, + "started_at": startedAt, + "duration_seconds": exam.DurationMinutes * 60, + "elapsed_seconds": jsonInt(existing["elapsed_seconds"]), + "paused": false, + "duration_minutes": exam.DurationMinutes, + "status": "in_progress", + "answered_count": jsonInt(existing["answered_count"]), + "answered_count_stale": false, + "total_questions": jsonInt(existing["total_questions"]), + "violation_count": session.ViolationCount, + } + if session.EndTime != nil { + snapshot["end_time"] = pythonISOTime(*session.EndTime) + } + paused := session.TotalPausedSeconds + if cached := jsonInt(existing["total_paused_seconds"]); cached > paused { + paused = cached + } + if paused > 0 { + snapshot["total_paused_seconds"] = paused + } + return snapshot +} + +func (s startService) storeSessionSnapshot(ctx context.Context, sessionID int, snapshot map[string]any) error { + encoded, err := json.Marshal(snapshot) + if err != nil { + return err + } + return s.repo.RedisSet(ctx, "exam_session:"+strconv.Itoa(sessionID), string(encoded), 2*time.Hour) +} + +type cachedStartQuestion struct { + ID int `json:"id"` + QuestionText string `json:"question_text"` + Stimulus *string `json:"stimulus"` + QuestionType string `json:"question_type"` + PgkType *string `json:"pgk_type"` + Points json.RawMessage `json:"points"` + OrderIndex int `json:"order_index"` + ImageURL *string `json:"image_url"` + VideoURL *string `json:"video_url"` + AudioURL *string `json:"audio_url"` + QuestionSettings json.RawMessage `json:"question_settings"` + Options []struct { + ID int `json:"id"` + OptionText string `json:"option_text"` + OrderIndex int `json:"order_index"` + OptionGroup string `json:"option_group"` + PairID *string `json:"pair_id"` + } `json:"options"` +} + +func (s startService) loadStartQuestions(ctx context.Context, examID int) ([]persistence.QuestionRow, *startHTTPError) { + cacheKey := "exam:" + strconv.Itoa(examID) + ":questions:payload:v1" + if raw, found, err := s.repo.RedisGet(ctx, cacheKey); err == nil && found { + if rows, ok := decodeCachedStartQuestions(raw, examID); ok { + return rows, nil + } + return nil, startError(500, "Internal Server Error") + } + release, err := s.gate.acquire(ctx) + if err != nil { + return nil, startError(500, "Internal Server Error") + } + rows, loadErr := s.repo.LoadQuestions(ctx, examID) + release() + if loadErr != nil { + return nil, startError(500, "Internal Server Error") + } + for index := range rows { + // FastAPI's v1 question cache omits difficulty_level. + rows[index].Difficulty = "" + } + if encoded, err := encodeCachedStartQuestions(rows); err == nil { + _ = s.repo.RedisSet(ctx, cacheKey, encoded, 30*time.Minute) + } + return rows, nil +} + +func decodeCachedStartQuestions(raw string, examID int) ([]persistence.QuestionRow, bool) { + var cached []cachedStartQuestion + if json.Unmarshal([]byte(raw), &cached) != nil { + return nil, false + } + rows := make([]persistence.QuestionRow, 0, len(cached)) + for _, item := range cached { + pointsText := strings.Trim(string(item.Points), "\"") + points, err := strconv.ParseFloat(pointsText, 64) + if err != nil { + points = 0 + } + row := persistence.QuestionRow{ + ID: item.ID, ExamID: examID, Text: item.QuestionText, + Stimulus: item.Stimulus, Type: item.QuestionType, PgkType: item.PgkType, + Settings: append([]byte(nil), item.QuestionSettings...), Points: points, + PointsText: pointsText, OrderIndex: item.OrderIndex, + ImageURL: item.ImageURL, VideoURL: item.VideoURL, AudioURL: item.AudioURL, + } + for _, option := range item.Options { + row.Options = append(row.Options, persistence.OptionRow{ + ID: option.ID, QuestionID: item.ID, Text: option.OptionText, + OrderIndex: option.OrderIndex, OptionGroup: option.OptionGroup, + PairID: option.PairID, + }) + } + rows = append(rows, row) + } + return rows, true +} + +func encodeCachedStartQuestions(rows []persistence.QuestionRow) (string, error) { + payload := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + settings := decodeQuestionSettings(row.Settings) + options := make([]map[string]any, 0, len(row.Options)) + for _, option := range row.Options { + group := option.OptionGroup + if group == "" { + group = "standard" + } + options = append(options, map[string]any{ + "id": option.ID, + "option_text": option.Text, + "order_index": option.OrderIndex, + "option_group": group, + "pair_id": option.PairID, + }) + } + points := row.PointsText + if points == "" { + points = pythonDecimalFromFloat(row.Points) + } + payload = append(payload, map[string]any{ + "id": row.ID, + "question_text": row.Text, + "stimulus": row.Stimulus, + "question_type": row.Type, + "pgk_type": row.PgkType, + "points": points, + "order_index": row.OrderIndex, + "image_url": row.ImageURL, + "video_url": row.VideoURL, + "audio_url": row.AudioURL, + "question_settings": settings, + "options": options, + }) + } + encoded, err := json.Marshal(payload) + return string(encoded), err +} + +func jsonInt(value any) int { + switch typed := value.(type) { + case int: + return typed + case float64: + return int(typed) + case json.Number: + parsed, _ := typed.Int64() + return int(parsed) + case string: + parsed, _ := strconv.Atoi(typed) + return parsed + default: + return 0 + } +} + +func pythonTime(value time.Time) string { + value = value.UTC().Truncate(time.Microsecond) + base := value.Format("2006-01-02T15:04:05") + if micros := value.Nanosecond() / 1000; micros > 0 { + base += fmt.Sprintf(".%06d", micros) + } + return base + "Z" +} + +func pythonISOTime(value time.Time) string { + value = value.UTC().Truncate(time.Microsecond) + base := value.Format("2006-01-02T15:04:05") + if micros := value.Nanosecond() / 1000; micros > 0 { + base += fmt.Sprintf(".%06d", micros) + } + return base + "+00:00" +} diff --git a/go/internal/exam/start_native_test.go b/go/internal/exam/start_native_test.go new file mode 100644 index 0000000..256b0f4 --- /dev/null +++ b/go/internal/exam/start_native_test.go @@ -0,0 +1,946 @@ +package exam + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "reflect" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgconn" + + "siab1/internal/auth" + "siab1/internal/persistence" +) + +type fakeStartRepo struct { + tx *fakeStartTx + txQueue []*fakeStartTx + security persistence.StartSecuritySettings + securityErr error + sebConfig string + sebBrowser string + sebFound bool + questions []persistence.QuestionRow + questionErr error + raced *persistence.StartSessionRow + raceErr error + redis map[string]string + redisSetErr map[string]error + redisGetErr map[string]error + publishErr error + published []string + xaddErr error + xaddKeys []string + xaddEvents []string + mu sync.Mutex +} + +func newFakeStartRepo(tx *fakeStartTx) *fakeStartRepo { + return &fakeStartRepo{ + tx: tx, + security: persistence.StartSecuritySettings{DeveloperMode: true, AllowMobileApps: true}, + sebConfig: "config-key", + sebFound: true, + redis: map[string]string{}, + redisSetErr: map[string]error{}, + redisGetErr: map[string]error{}, + } +} + +func (f *fakeStartRepo) BeginStart(context.Context) (persistence.StartTransaction, error) { + f.mu.Lock() + if len(f.txQueue) > 0 { + tx := f.txQueue[0] + f.txQueue = f.txQueue[1:] + f.mu.Unlock() + return tx, tx.beginErr + } + f.mu.Unlock() + if f.tx.beginErr != nil { + return nil, f.tx.beginErr + } + return f.tx, nil +} + +func (f *fakeStartRepo) LoadStartSecuritySettings(context.Context) (persistence.StartSecuritySettings, error) { + return f.security, f.securityErr +} + +func (f *fakeStartRepo) StartSEBKeys(context.Context, int) (string, string, bool, error) { + return f.sebConfig, f.sebBrowser, f.sebFound, nil +} + +func (f *fakeStartRepo) CanonicalActiveStartSession(context.Context, int, int) (*persistence.StartSessionRow, error) { + return f.raced, f.raceErr +} + +func (f *fakeStartRepo) LoadQuestions(context.Context, int) ([]persistence.QuestionRow, error) { + return append([]persistence.QuestionRow(nil), f.questions...), f.questionErr +} + +func (f *fakeStartRepo) RedisGet(_ context.Context, key string) (string, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.redisGetErr[key]; err != nil { + return "", false, err + } + value, ok := f.redis[key] + return value, ok, nil +} + +func (f *fakeStartRepo) RedisSet(_ context.Context, key, value string, _ time.Duration) error { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.redisSetErr[key]; err != nil { + return err + } + f.redis[key] = value + return nil +} + +func (f *fakeStartRepo) RedisSetNX(_ context.Context, key, value string, _ time.Duration) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + if _, exists := f.redis[key]; exists { + return false, nil + } + f.redis[key] = value + return true, nil +} + +func (f *fakeStartRepo) RedisDelete(_ context.Context, key string) error { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.redis, key) + return nil +} + +func (f *fakeStartRepo) RedisPublish(_ context.Context, channel, value string) error { + if f.publishErr != nil { + return f.publishErr + } + f.published = append(f.published, channel+":"+value) + return nil +} + +func (f *fakeStartRepo) RedisXAdd(_ context.Context, key, event string, _, _ int) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.xaddErr != nil { + return f.xaddErr + } + f.xaddKeys = append(f.xaddKeys, key) + f.xaddEvents = append(f.xaddEvents, event) + return nil +} + +type fakeStartTx struct { + exam *persistence.StartExamRow + state persistence.StartSessionState + answerCounts map[int]int + logs map[int][]persistence.SessionLog + orphans []int + createdSession *persistence.StartSessionRow + recovered *persistence.StartSessionRow + beginErr error + examErr error + stateErr error + createErr error + recoverErr error + commitErr error + createCalled int + logAdded int + recoverCalled int + commits int + rollbacks int + rollbackCanceled bool +} + +func (f *fakeStartTx) Exam(context.Context, int) (*persistence.StartExamRow, error) { + return f.exam, f.examErr +} + +func (f *fakeStartTx) ValidateOptionIntegrity(context.Context, int) ([]int, error) { + return f.orphans, nil +} + +func (f *fakeStartTx) SessionState(context.Context, int, int) (persistence.StartSessionState, error) { + return f.state, f.stateErr +} + +func (f *fakeStartTx) AnswerCounts(context.Context, []int) (map[int]int, error) { + return f.answerCounts, nil +} + +func (f *fakeStartTx) SessionLogs(_ context.Context, sessionID, _ int) ([]persistence.SessionLog, error) { + return f.logs[sessionID], nil +} + +func (f *fakeStartTx) RecoverSession( + _ context.Context, + session persistence.StartSessionRow, + _, _ string, +) (*persistence.StartSessionRow, error) { + f.recoverCalled++ + if f.recoverErr != nil { + return nil, f.recoverErr + } + if f.recovered != nil { + return f.recovered, nil + } + session.Status = "in_progress" + session.EndTime = nil + return &session, nil +} + +func (f *fakeStartTx) CreateSessionWithLog( + context.Context, + int, + int, + persistence.StartClientInfo, + persistence.SessionStartLog, +) (*persistence.StartSessionRow, error) { + f.createCalled++ + if f.createErr != nil { + return nil, f.createErr + } + f.logAdded++ + return f.createdSession, nil +} + +func (f *fakeStartTx) Commit(context.Context) error { + if f.commitErr != nil { + return f.commitErr + } + f.commits++ + return nil +} + +func (f *fakeStartTx) Rollback(ctx context.Context) error { + f.rollbacks++ + f.rollbackCanceled = ctx.Err() != nil + return nil +} + +func validStartExam() *persistence.StartExamRow { + now := time.Now().UTC() + showTeacher := true + teacher := "Guru" + role := "teacher" + return &persistence.StartExamRow{ + ID: 7, CreatorID: 11, Published: true, + StartTime: now.Add(-time.Hour), EndTime: now.Add(time.Hour), + MaxAttempts: 2, DurationMinutes: 60, + Title: "Ujian", Subject: stringPointer("MTK"), ExamType: stringPointer("UH"), + ShowTeacherName: &showTeacher, TeacherName: &teacher, CreatorRole: &role, + } +} + +func validQuestion() persistence.QuestionRow { + return persistence.QuestionRow{ + ID: 1, ExamID: 7, Text: "Soal", Type: "multiple_choice", + Points: 1, PointsText: "1.00", OrderIndex: 0, Settings: []byte(`{}`), + Options: []persistence.OptionRow{ + {ID: 11, QuestionID: 1, Text: "A", OrderIndex: 0, OptionGroup: "standard"}, + {ID: 12, QuestionID: 1, Text: "B", OrderIndex: 1, OptionGroup: "standard"}, + }, + } +} + +func validStartTx() *fakeStartTx { + return &fakeStartTx{ + exam: validStartExam(), + answerCounts: map[int]int{}, + logs: map[int][]persistence.SessionLog{}, + createdSession: &persistence.StartSessionRow{ + ID: 99, UserID: 5, ExamID: 7, Status: "in_progress", + StartTime: time.Now().UTC().Truncate(time.Microsecond), + }, + } +} + +func validService(repo *fakeStartRepo) startService { + return startService{ + repo: repo, + gate: newStartAdmission(4), + jwtSecret: "jwt-secret", + appSecret: "app-secret", + defaultSEBKey: "default-seb-config-key", + challengeEnabled: true, + challengePrefix: "seb:challenge:", + monitoringDelta: true, + monitoringDeltaMaxLen: 5000, + monitoringDeltaTTL: 7200, + } +} + +func validStartRequest(t *testing.T, active bool) *http.Request { + t.Helper() + token, err := auth.SignUser("jwt-secret", 5, "student", "student", "Student", "XII", active) + if err != nil { + t.Fatal(err) + } + r := httptest.NewRequest(http.MethodPost, "https://example.test/api/exams/7/start", nil) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("User-Agent", "test") + return r +} + +func TestNativeStartNormalCreatesAtomicSessionAndSnapshot(t *testing.T) { + tx := validStartTx() + repo := newFakeStartRepo(tx) + repo.questions = []persistence.QuestionRow{validQuestion()} + response, startErr := validService(repo).start(validStartRequest(t, true), 7) + if startErr != nil { + t.Fatal(startErr) + } + if response.SessionID != 99 || response.QuestionCount != 1 { + t.Fatalf("response=%+v", response) + } + if tx.createCalled != 1 || tx.logAdded != 1 || tx.commits != 1 || tx.rollbacks != 0 { + t.Fatalf("transaction create=%d log=%d commit=%d rollback=%d", tx.createCalled, tx.logAdded, tx.commits, tx.rollbacks) + } + raw, ok := repo.redis["exam_session:99"] + if !ok { + t.Fatal("missing Redis session snapshot") + } + var snapshot map[string]any + if err := json.Unmarshal([]byte(raw), &snapshot); err != nil { + t.Fatal(err) + } + for _, key := range []string{ + "session_id", "user_id", "exam_id", "start_time", "started_at", + "duration_seconds", "elapsed_seconds", "paused", "duration_minutes", + "status", "answered_count", "answered_count_stale", "total_questions", + "violation_count", + } { + if _, exists := snapshot[key]; !exists { + t.Fatalf("missing snapshot field %s", key) + } + } + if snapshot["status"] != "in_progress" || jsonInt(snapshot["session_id"]) != 99 { + t.Fatalf("snapshot=%v", snapshot) + } + if len(repo.published) != 1 { + t.Fatalf("published=%v", repo.published) + } + if len(repo.xaddKeys) != 1 || repo.xaddKeys[0] != "monitoring:delta:exam:7" { + t.Fatalf("delta=%v", repo.xaddKeys) + } + var delta map[string]any + if err := json.Unmarshal([]byte(repo.xaddEvents[0]), &delta); err != nil { + t.Fatal(err) + } + if delta["event_type"] != "student_started" { + t.Fatalf("delta=%v", delta) + } + payload, _ := delta["payload"].(map[string]any) + if payload["type"] != "student_started" || jsonInt(payload["user_id"]) != 5 || jsonInt(payload["session_id"]) != 99 { + t.Fatalf("payload=%v", payload) + } + if _, ok := delta["ts"].(string); !ok { + t.Fatalf("missing delta ts: %v", delta) + } + if response.Questions[0].DifficultyLevel != "medium" || response.Questions[0].Points != "1.0" { + t.Fatalf("question=%+v", response.Questions[0]) + } + rawResponse, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + var encoded map[string]any + if err := json.Unmarshal(rawResponse, &encoded); err != nil { + t.Fatal(err) + } + fixture := loadStartParityFixture(t) + for _, key := range fixture.ResponseKeys { + if _, exists := encoded[key]; !exists { + t.Fatalf("missing response field %s", key) + } + } + if extra := extraJSONKeys(encoded, fixture.ResponseKeys); len(extra) > 0 { + t.Fatalf("extra response fields %v", extra) + } +} + +func TestNativeStartMissingAuthMatchesFastAPI(t *testing.T) { + repo := newFakeStartRepo(validStartTx()) + req := httptest.NewRequest(http.MethodPost, "https://example.test/api/exams/7/start", nil) + _, startErr := validService(repo).start(req, 7) + if startErr == nil || startErr.Status != http.StatusForbidden || startErr.Detail != "Not authenticated" { + t.Fatalf("error=%v", startErr) + } +} + +func TestNativeStartMonitoringXAddFailOpen(t *testing.T) { + tx := validStartTx() + repo := newFakeStartRepo(tx) + repo.questions = []persistence.QuestionRow{validQuestion()} + repo.xaddErr = errors.New("xadd down") + response, startErr := validService(repo).start(validStartRequest(t, true), 7) + if startErr != nil || response == nil || response.SessionID != 99 { + t.Fatalf("error=%v response=%v", startErr, response) + } + if tx.commits != 1 || tx.rollbacks != 0 { + t.Fatalf("commit=%d rollback=%d", tx.commits, tx.rollbacks) + } + if _, ok := repo.redis["exam_session:99"]; !ok { + t.Fatal("missing Redis session snapshot after XADD failure") + } + if len(repo.published) != 1 { + t.Fatalf("published=%v", repo.published) + } + if len(repo.xaddKeys) != 0 { + t.Fatalf("xaddKeys=%v", repo.xaddKeys) + } +} + +func TestNativeStartDifficultyAndPointsMatchFastAPI(t *testing.T) { + tx := validStartTx() + repo := newFakeStartRepo(tx) + hard := validQuestion() + hard.Difficulty = "hard" + hard.Points = 1 + hard.PointsText = "1.00" + repo.questions = []persistence.QuestionRow{hard} + response, startErr := validService(repo).start(validStartRequest(t, true), 7) + if startErr != nil { + t.Fatal(startErr) + } + fixture := loadStartParityFixture(t) + if response.Questions[0].DifficultyLevel != fixture.DifficultyDefault { + t.Fatalf("difficulty=%q", response.Questions[0].DifficultyLevel) + } + if response.Questions[0].Points != fixture.Points.From100 { + t.Fatalf("points=%q", response.Questions[0].Points) + } + + cases := []struct { + points float64 + want string + }{ + {1, fixture.Points.IntLike}, + {1.25, fixture.Points.Fractional}, + {0, fixture.Points.Zero}, + } + for _, tc := range cases { + questions, err := buildStartQuestions( + []persistence.QuestionRow{{ + ID: 1, Text: "Soal", Type: "essay", Points: tc.points, OrderIndex: 0, Settings: []byte(`{}`), + }}, + 7, 5, false, false, "app-secret", + ) + if err != nil { + t.Fatal(err) + } + if questions[0].Points != tc.want { + t.Fatalf("points(%v)=%q want %q", tc.points, questions[0].Points, tc.want) + } + } + + questions, err := buildStartQuestions( + []persistence.QuestionRow{validQuestion()}, + 7, 5, false, false, "app-secret", + ) + if err != nil { + t.Fatal(err) + } + raw, marshalErr := json.Marshal(questions[0]) + if marshalErr != nil { + t.Fatal(marshalErr) + } + var encoded map[string]any + if json.Unmarshal(raw, &encoded) != nil { + t.Fatal("question json") + } + for _, key := range fixture.QuestionKeys { + if _, exists := encoded[key]; !exists { + t.Fatalf("missing question field %s", key) + } + } + if extra := extraJSONKeys(encoded, fixture.QuestionKeys); len(extra) > 0 { + t.Fatalf("extra question fields %v", extra) + } + option, _ := encoded["options"].([]any)[0].(map[string]any) + for _, key := range fixture.OptionKeys { + if _, exists := option[key]; !exists { + t.Fatalf("missing option field %s", key) + } + } + if extra := extraJSONKeys(option, fixture.OptionKeys); len(extra) > 0 { + t.Fatalf("extra option fields %v", extra) + } + if encoded["difficulty_level"] != "medium" || encoded["points"] != "1.0" || encoded["category"] != nil { + t.Fatalf("question json=%s", raw) + } +} + +func TestNativeStartInvalidTokenMatchesFastAPI(t *testing.T) { + repo := newFakeStartRepo(validStartTx()) + req := httptest.NewRequest(http.MethodPost, "https://example.test/api/exams/7/start", nil) + req.Header.Set("Authorization", "Bearer not-a-jwt") + _, startErr := validService(repo).start(req, 7) + if startErr == nil || startErr.Status != http.StatusUnauthorized || startErr.Detail != "Token tidak valid atau sudah kadaluarsa" { + t.Fatalf("error=%v", startErr) + } +} + +func TestNativeStartSEBRequired(t *testing.T) { + repo := newFakeStartRepo(validStartTx()) + repo.security = persistence.StartSecuritySettings{AllowMobileApps: true} + req := validStartRequest(t, true) + _, startErr := validService(repo).start(req, 7) + if startErr == nil || startErr.Status != http.StatusForbidden { + t.Fatalf("error=%v", startErr) + } + detail, _ := startErr.Detail.(map[string]any) + if detail["error"] != "SEB_REQUIRED" { + t.Fatalf("detail=%v", startErr.Detail) + } +} + +func TestNativeStartRejectsInactiveJWT(t *testing.T) { + tx := validStartTx() + repo := newFakeStartRepo(tx) + _, startErr := validService(repo).start(validStartRequest(t, false), 7) + if startErr == nil || startErr.Status != http.StatusForbidden || startErr.Detail != "Akun tidak aktif" { + t.Fatalf("error=%v", startErr) + } + if tx.createCalled != 0 { + t.Fatal("inactive account reached transaction") + } +} + +func TestNativeStartMaxAttempts(t *testing.T) { + tx := validStartTx() + tx.state.AttemptCount = tx.exam.MaxAttempts + repo := newFakeStartRepo(tx) + _, _, _, _, startErr := validService(repo).startTransaction( + context.Background(), validStartRequest(t, true), 7, 5, + &auth.Claims{Role: "student", StudentClass: "XII"}, + ) + if startErr == nil || startErr.Status != 400 || startErr.Detail != "Batas percobaan sudah tercapai" { + t.Fatalf("error=%v", startErr) + } + if tx.createCalled != 0 || tx.rollbacks != 1 { + t.Fatalf("create=%d rollback=%d", tx.createCalled, tx.rollbacks) + } +} + +func TestNativeStartInvalidAccess(t *testing.T) { + tx := validStartTx() + allowed := "XI" + tx.exam.AllowedClasses = &allowed + repo := newFakeStartRepo(tx) + _, _, _, _, startErr := validService(repo).startTransaction( + context.Background(), validStartRequest(t, true), 7, 5, + &auth.Claims{Role: "student", StudentClass: "XII"}, + ) + if startErr == nil || startErr.Status != 403 { + t.Fatalf("error=%v", startErr) + } +} + +func TestNativeStartResumesCanonicalSession(t *testing.T) { + tx := validStartTx() + tx.state.Sessions = []persistence.StartSessionRow{ + {ID: 40, Status: "active", StartTime: time.Now().Add(-time.Minute)}, + {ID: 41, Status: "in_progress", StartTime: time.Now()}, + } + tx.answerCounts = map[int]int{40: 5, 41: 2} + repo := newFakeStartRepo(tx) + exam, session, resumed, _, startErr := validService(repo).startTransaction( + context.Background(), validStartRequest(t, true), 7, 5, + &auth.Claims{Role: "student", StudentClass: "XII"}, + ) + if startErr != nil || exam == nil || !resumed || session.ID != 40 { + t.Fatalf("session=%+v resumed=%v error=%v", session, resumed, startErr) + } + if tx.createCalled != 0 || tx.commits != 1 { + t.Fatalf("create=%d commit=%d", tx.createCalled, tx.commits) + } +} + +func TestNativeStartRecoversNetworkTermination(t *testing.T) { + tx := validStartTx() + tx.state.Sessions = []persistence.StartSessionRow{ + {ID: 41, Status: "terminated", StartTime: time.Now(), TerminatedByAdmin: false}, + } + repo := newFakeStartRepo(tx) + _, session, resumed, _, startErr := validService(repo).startTransaction( + context.Background(), validStartRequest(t, true), 7, 5, + &auth.Claims{Role: "student", StudentClass: "XII"}, + ) + if startErr != nil || !resumed || session.ID != 41 || session.Status != "in_progress" { + t.Fatalf("session=%+v resumed=%v error=%v", session, resumed, startErr) + } + if tx.recoverCalled != 1 || tx.commits != 1 { + t.Fatalf("recover=%d commit=%d", tx.recoverCalled, tx.commits) + } +} + +func TestNativeStartBlocksAnyAdminTerminationBeforeRecovery(t *testing.T) { + tx := validStartTx() + tx.state.Sessions = []persistence.StartSessionRow{ + {ID: 42, Status: "terminated", StartTime: time.Now()}, + {ID: 41, Status: "kicked", StartTime: time.Now().Add(-time.Minute), TerminatedByAdmin: true}, + } + repo := newFakeStartRepo(tx) + _, _, _, _, startErr := validService(repo).startTransaction( + context.Background(), validStartRequest(t, true), 7, 5, + &auth.Claims{Role: "student", StudentClass: "XII"}, + ) + if startErr == nil || startErr.Status != 409 || tx.recoverCalled != 0 { + t.Fatalf("error=%v recover=%d", startErr, tx.recoverCalled) + } +} + +func TestNativeStartDuplicateRaceReturnsCanonicalSession(t *testing.T) { + tx := validStartTx() + tx.createErr = &pgconn.PgError{Code: "23505"} + repo := newFakeStartRepo(tx) + repo.raced = &persistence.StartSessionRow{ + ID: 77, UserID: 5, ExamID: 7, Status: "in_progress", StartTime: time.Now(), + } + _, session, resumed, _, startErr := validService(repo).startTransaction( + context.Background(), validStartRequest(t, true), 7, 5, + &auth.Claims{Role: "student", StudentClass: "XII"}, + ) + if startErr != nil || !resumed || session.ID != 77 { + t.Fatalf("session=%+v resumed=%v error=%v", session, resumed, startErr) + } + if tx.rollbacks != 1 || tx.commits != 0 { + t.Fatalf("rollback=%d commit=%d", tx.rollbacks, tx.commits) + } +} + +func TestNativeStartSimultaneousDuplicateRequestsConverge(t *testing.T) { + first := validStartTx() + second := validStartTx() + second.createErr = &pgconn.PgError{Code: "23505"} + repo := newFakeStartRepo(first) + repo.txQueue = []*fakeStartTx{first, second} + repo.raced = first.createdSession + service := validService(repo) + claims := &auth.Claims{Role: "student", StudentClass: "XII"} + request := validStartRequest(t, true) + type result struct { + session *persistence.StartSessionRow + err *startHTTPError + } + results := make(chan result, 2) + for i := 0; i < 2; i++ { + go func() { + _, session, _, _, startErr := service.startTransaction( + context.Background(), request, 7, 5, claims, + ) + results <- result{session: session, err: startErr} + }() + } + for i := 0; i < 2; i++ { + result := <-results + if result.err != nil || result.session == nil || result.session.ID != 99 { + t.Fatalf("session=%+v error=%v", result.session, result.err) + } + } + if first.commits != 1 || second.rollbacks != 1 { + t.Fatalf("first commits=%d second rollbacks=%d", first.commits, second.rollbacks) + } +} + +func TestNativeStartFailurePathsRollback(t *testing.T) { + tests := []struct { + name string + configure func(*fakeStartTx) + }{ + {"db", func(tx *fakeStartTx) { tx.examErr = errors.New("db") }}, + {"log", func(tx *fakeStartTx) { tx.createErr = errors.New("log") }}, + {"commit", func(tx *fakeStartTx) { tx.commitErr = errors.New("commit") }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tx := validStartTx() + test.configure(tx) + repo := newFakeStartRepo(tx) + _, _, _, _, startErr := validService(repo).startTransaction( + context.Background(), validStartRequest(t, true), 7, 5, + &auth.Claims{Role: "student", StudentClass: "XII"}, + ) + if startErr == nil || startErr.Status != 500 || tx.commits != 0 || tx.rollbacks != 1 { + t.Fatalf("error=%v commit=%d rollback=%d", startErr, tx.commits, tx.rollbacks) + } + }) + } +} + +func TestNativeStartRedisFailureOccursAfterCommit(t *testing.T) { + tx := validStartTx() + repo := newFakeStartRepo(tx) + repo.questions = []persistence.QuestionRow{validQuestion()} + repo.redisSetErr["exam_session:99"] = errors.New("redis down") + _, startErr := validService(repo).start(validStartRequest(t, true), 7) + if startErr == nil || startErr.Status != 500 { + t.Fatalf("error=%v", startErr) + } + if tx.commits != 1 || tx.rollbacks != 0 { + t.Fatalf("commit=%d rollback=%d", tx.commits, tx.rollbacks) + } +} + +func TestStartSecurityParity(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "https://example.test/api/exams/7/start", nil) + settings := persistence.StartSecuritySettings{} + if err := validateStartSXB(request, settings, true); err == nil || err.Status != 403 { + t.Fatalf("missing SXB should fail: %v", err) + } + settings.DeveloperMode = true + if err := validateStartSXB(request, settings, true); err != nil { + t.Fatalf("developer mode should bypass: %v", err) + } + settings = persistence.StartSecuritySettings{ + AllowMobileApps: true, + MinimumAPKToken: "BUILD-20260125120000-ABC123", + } + request.Header.Set("User-Agent", "SXB-Client") + request.Header.Set("X-Build-Token", "BUILD-20260125120000-ABC123") + repo := newFakeStartRepo(validStartTx()) + if err := validateStartSEB( + context.Background(), repo, request, 7, settings, + "default-seb-config-key", true, "seb:challenge:", + ); err != nil { + t.Fatalf("trusted mobile should bypass SEB: %v", err) + } + request.Header.Del("X-Build-Token") + configHash := sha256.Sum256([]byte(repo.sebConfig)) + request.Header.Set("X-SafeExamBrowser-ConfigKeyHash", hex.EncodeToString(configHash[:])) + if err := validateStartSEB( + context.Background(), repo, request, 7, settings, + "default-seb-config-key", true, "seb:challenge:", + ); err != nil { + t.Fatalf("valid SEB hash should pass: %v", err) + } + request.Header.Set("X-SafeExamBrowser-ConfigKeyHash", "deadbeef") + invalid := validateStartSEB( + context.Background(), repo, request, 7, settings, + "default-seb-config-key", true, "seb:challenge:", + ) + if invalid == nil || invalid.Status != http.StatusForbidden { + t.Fatalf("invalid SEB hash should fail: %v", invalid) + } + detail, _ := invalid.Detail.(map[string]any) + if detail["error"] != "INVALID_SEB_CONFIG" { + t.Fatalf("detail=%v", invalid.Detail) + } +} + +func TestStartAdmissionLimitAndCancellation(t *testing.T) { + gate := newStartAdmission(4) + var current atomic.Int32 + var peak atomic.Int32 + releaseAll := make(chan struct{}) + started := make(chan struct{}, 8) + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + release, err := gate.acquire(context.Background()) + if err != nil { + return + } + value := current.Add(1) + for value > peak.Load() && !peak.CompareAndSwap(peak.Load(), value) { + } + started <- struct{}{} + <-releaseAll + current.Add(-1) + release() + }() + } + for i := 0; i < 4; i++ { + <-started + } + deadline := time.Now().Add(time.Second) + for gate.snapshot().Waiters != 4 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if snapshot := gate.snapshot(); snapshot.Holders != 4 || snapshot.Waiters != 4 { + t.Fatalf("snapshot=%+v", snapshot) + } + close(releaseAll) + wg.Wait() + if peak.Load() > 4 || gate.snapshot().Holders != 0 { + t.Fatalf("peak=%d snapshot=%+v", peak.Load(), gate.snapshot()) + } + + holderReleases := make([]func(), 0, 4) + for i := 0; i < 4; i++ { + release, err := gate.acquire(context.Background()) + if err != nil { + t.Fatal(err) + } + holderReleases = append(holderReleases, release) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if release, err := gate.acquire(ctx); !errors.Is(err, context.Canceled) || release != nil { + t.Fatalf("release_nil=%v error=%v", release == nil, err) + } + for _, release := range holderReleases { + release() + } +} + +func TestCancellationRollbackUsesLiveContext(t *testing.T) { + tx := validStartTx() + tx.examErr = context.Canceled + repo := newFakeStartRepo(tx) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, _, _, _, startErr := validService(repo).startTransaction( + ctx, validStartRequest(t, true), 7, 5, + &auth.Claims{Role: "student", StudentClass: "XII"}, + ) + if startErr == nil || tx.rollbacks != 1 || tx.rollbackCanceled { + t.Fatalf("error=%v rollback=%d canceled=%v", startErr, tx.rollbacks, tx.rollbackCanceled) + } +} + +func TestBuildStartQuestionParityFixtures(t *testing.T) { + fixture := loadStartParityFixture(t) + rows := []persistence.QuestionRow{ + {ID: 11, Text: "Q11", Type: "multiple_choice", Points: 1, OrderIndex: 0, Settings: []byte(`{}`), Options: parityOptions()}, + {ID: 12, Text: "Q12", Type: "multiple_choice", Points: 1, OrderIndex: 1, Settings: []byte(`{}`), Options: parityOptions()}, + {ID: 13, Text: "Q13", Type: "multiple_choice", Points: 1, OrderIndex: 2, Settings: []byte(`{}`), Options: parityOptions()}, + {ID: 14, Text: "Q14", Type: "multiple_choice", Points: 1, OrderIndex: 3, Settings: []byte(`{}`), Options: parityOptions()}, + } + questions, startErr := buildStartQuestions(rows, 9, 42, true, true, "test-secret") + if startErr != nil { + t.Fatal(startErr) + } + ids := make([]int, 0, len(questions)) + for _, question := range questions { + ids = append(ids, question.ID) + } + if !reflect.DeepEqual(ids, fixture.QuestionOrder) { + t.Fatalf("question order=%v", ids) + } + var options []int + for _, question := range questions { + if question.ID == 11 { + for _, option := range question.Options { + options = append(options, option.ID) + } + } + } + if !reflect.DeepEqual(options, fixture.OptionOrderQuestion11) { + t.Fatalf("option order=%v", options) + } +} + +func TestBuildStartTableAndImageParity(t *testing.T) { + fixture := loadStartParityFixture(t) + pgk := "table_validation" + image := "/static/q.png" + rows := []persistence.QuestionRow{ + { + ID: 21, Text: "Tabel", Type: "multiple_choice_complex", PgkType: &pgk, + Points: 1, Settings: []byte(`{"allow_table_statement_shuffle":true,"statements":["A","B","C"]}`), + }, + { + ID: 22, Text: "", Type: "multiple_choice", Points: 1, ImageURL: &image, + Settings: []byte(`{"is_placeholder":true,"placeholder_source":"image","allow_placeholder_shuffle":true}`), + Options: parityOptions(), + }, + } + questions, startErr := buildStartQuestions(rows, 3, 7, false, true, "test-secret") + if startErr != nil { + t.Fatal(startErr) + } + statements := questions[0].QuestionSettings["statements"].([]any) + indexes := make([]int, 0, len(statements)) + for _, raw := range statements { + indexes = append(indexes, raw.(map[string]any)["original_index"].(int)) + } + if !reflect.DeepEqual(indexes, fixture.TableStatementOrder) { + t.Fatalf("statement order=%v", indexes) + } + if questions[1].QuestionText != fixture.ImagePlaceholderText { + t.Fatalf("image fallback=%q", questions[1].QuestionText) + } + optionIDs := []int{} + for _, option := range questions[1].Options { + optionIDs = append(optionIDs, option.ID) + } + if !reflect.DeepEqual(optionIDs, fixture.ImagePlaceholderOptionOrder) { + t.Fatalf("image placeholder options shuffled: %v", optionIDs) + } +} + +func parityOptions() []persistence.OptionRow { + return []persistence.OptionRow{ + {ID: 1, Text: "A", OrderIndex: 0, OptionGroup: "standard"}, + {ID: 2, Text: "B", OrderIndex: 1, OptionGroup: "standard"}, + {ID: 3, Text: "C", OrderIndex: 2, OptionGroup: "standard"}, + {ID: 4, Text: "D", OrderIndex: 3, OptionGroup: "standard"}, + } +} + +func stringPointer(value string) *string { + return &value +} + +type startParityFixture struct { + StableShuffle []int `json:"stable_shuffle"` + QuestionOrder []int `json:"question_order"` + OptionOrderQuestion11 []int `json:"option_order_question_11"` + TableStatementOrder []int `json:"table_statement_order"` + ImagePlaceholderText string `json:"image_placeholder_text"` + ImagePlaceholderOptionOrder []int `json:"image_placeholder_option_order"` + QuestionKeys []string `json:"question_keys"` + OptionKeys []string `json:"option_keys"` + ResponseKeys []string `json:"response_keys"` + DifficultyDefault string `json:"difficulty_default"` + Points struct { + IntLike string `json:"int_like"` + From100 string `json:"from_1_00"` + Fractional string `json:"fractional"` + Zero string `json:"zero"` + } `json:"points"` +} + +func extraJSONKeys(encoded map[string]any, allowed []string) []string { + permitted := map[string]struct{}{} + for _, key := range allowed { + permitted[key] = struct{}{} + } + extra := make([]string, 0) + for key := range encoded { + if _, ok := permitted[key]; !ok { + extra = append(extra, key) + } + } + return extra +} + +func loadStartParityFixture(t *testing.T) startParityFixture { + t.Helper() + raw, err := os.ReadFile("testdata/fastapi_start_parity.json") + if err != nil { + t.Fatal(err) + } + var fixture startParityFixture + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatal(err) + } + return fixture +} diff --git a/go/internal/exam/start_security.go b/go/internal/exam/start_security.go new file mode 100644 index 0000000..4bbf8f1 --- /dev/null +++ b/go/internal/exam/start_security.go @@ -0,0 +1,307 @@ +package exam + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "siab1/internal/persistence" +) + +var buildTokenPattern = regexp.MustCompile(`^BUILD-\d{14}-[A-Z0-9]{6}$`) + +type startSecurityRepository interface { + LoadStartSecuritySettings(context.Context) (persistence.StartSecuritySettings, error) + StartSEBKeys(context.Context, int) (string, string, bool, error) + RedisGet(context.Context, string) (string, bool, error) + RedisSet(context.Context, string, string, time.Duration) error + RedisDelete(context.Context, string) error +} + +func validateStartSXB( + r *http.Request, + settings persistence.StartSecuritySettings, + enforce bool, +) *startHTTPError { + if !enforce || settings.DeveloperMode { + return nil + } + ua := strings.ToLower(r.Header.Get("User-Agent")) + isSXB := strings.Contains(ua, "sxb-client") || strings.Contains(ua, "exambro") + isSEB := strings.Contains(ua, "seb") || strings.Contains(ua, "safe exam browser") + if !isSXB && !isSEB { + return &startHTTPError{ + Status: http.StatusForbidden, + Detail: "Akses ditolak. Gunakan Aplikasi Ujian (APK) atau Safe Exam Browser.", + RedirectHTML: "/student/dashboard.html", + } + } + if !isSXB { + return nil + } + signature := r.Header.Get("X-App-Signature") + timestamp := r.Header.Get("X-App-Timestamp") + if signature == "" || timestamp == "" { + return nil + } + allowed := parseSignatureProfiles(settings.AllowedSignatures) + if len(allowed) == 0 { + return startError(http.StatusForbidden, "Sistem APK belum dikonfigurasi. Hubungi admin untuk mengatur App Signatures.") + } + normalized := normalizeSignature(signature) + if _, ok := allowed[normalized]; !ok { + return startError(http.StatusForbidden, "Invalid App Signature. Unofficial app detected.") + } + clientTime, err := strconv.ParseInt(timestamp, 10, 64) + if err == nil && abs64(time.Now().Unix()-clientTime) > 3600 { + return startError(http.StatusForbidden, "Request Expired (Check Device Time)") + } + return nil +} + +func validateStartSEB( + ctx context.Context, + repo startSecurityRepository, + r *http.Request, + examID int, + settings persistence.StartSecuritySettings, + defaultConfigKey string, + challengeEnabled bool, + challengePrefix string, +) *startHTTPError { + if settings.DeveloperMode { + return nil + } + buildToken := r.Header.Get("X-Build-Token") + ua := strings.ToLower(r.Header.Get("User-Agent")) + isMobile := strings.Contains(ua, "sxb-client") || strings.Contains(ua, "exambro") + if buildToken != "" || isMobile { + if settings.AllowMobileApps { + tokenValid := false + if buildTokenPattern.MatchString(buildToken) { + _, tokenValid = parseTokenProfiles(settings.MinimumAPKToken)[strings.ToUpper(strings.TrimSpace(buildToken))] + } + signatureValid := false + if signature := r.Header.Get("X-App-Signature"); signature != "" { + _, signatureValid = parseSignatureProfiles(settings.AllowedSignatures)[normalizeSignature(signature)] + if signatureValid { + clientTime, err := strconv.ParseInt(r.Header.Get("X-App-Timestamp"), 10, 64) + signatureValid = err == nil && abs64(time.Now().Unix()-clientTime) <= 3600 + } + } + if tokenValid || signatureValid { + return nil + } + } + } + + configHash := r.Header.Get("X-SafeExamBrowser-ConfigKeyHash") + if configHash == "" { + return sebStartError( + examID, + "SEB_REQUIRED", + "Ujian ini harus diakses melalui Aplikasi Ujian (APK) atau Safe Exam Browser", + ) + } + configKey, browserKey, found, err := repo.StartSEBKeys(ctx, examID) + if err != nil { + return startError(http.StatusInternalServerError, "Internal Server Error") + } + if !found { + return startError(http.StatusNotFound, "Ujian tidak ditemukan") + } + expectedConfig := sha256.Sum256([]byte(configKey)) + receivedConfig, err := hex.DecodeString(strings.ToLower(configHash)) + if err != nil || !hmac.Equal(receivedConfig, expectedConfig[:]) { + return sebStartError(examID, "INVALID_SEB_CONFIG", "Konfigurasi SEB tidak valid") + } + requestHash := r.Header.Get("X-SafeExamBrowser-RequestHash") + if browserKey != "" && requestHash != "" { + mac := hmac.New(sha256.New, []byte(browserKey)) + _, _ = mac.Write([]byte(startRequestURL(r))) + received, err := hex.DecodeString(strings.ToLower(requestHash)) + if err != nil || !hmac.Equal(received, mac.Sum(nil)) { + return sebStartError(examID, "INVALID_REQUEST_HASH", "Verifikasi permintaan gagal") + } + } + challengeToken := r.Header.Get("X-SEB-Challenge-Token") + challengeResponse := r.Header.Get("X-SEB-Challenge-Response") + if challengeEnabled && challengeToken != "" && challengeResponse != "" { + if !validateStartChallenge( + ctx, repo, challengePrefix, challengeToken, challengeResponse, + defaultConfigKey, examID, + ) { + return sebStartError( + examID, + "CHALLENGE_FAILED", + "Validasi challenge gagal. Kemungkinan serangan spoofing terdeteksi.", + ) + } + } + return nil +} + +func validateStartChallenge( + ctx context.Context, + repo startSecurityRepository, + prefix, token, response, configKey string, + examID int, +) bool { + key := prefix + token + raw, found, err := repo.RedisGet(ctx, key) + if err != nil || !found { + return false + } + var data struct { + ExamID int `json:"exam_id"` + Used bool `json:"used"` + } + if json.Unmarshal([]byte(raw), &data) != nil { + return false + } + if data.Used { + _ = repo.RedisDelete(ctx, key) + return false + } + if data.ExamID != examID { + return false + } + expected := sha256.Sum256([]byte(token + configKey + strconv.Itoa(examID))) + received, err := hex.DecodeString(strings.ToLower(response)) + if err != nil || !hmac.Equal(received, expected[:]) { + return false + } + var payload map[string]any + if json.Unmarshal([]byte(raw), &payload) != nil { + return false + } + payload["used"] = true + encoded, err := json.Marshal(payload) + return err == nil && repo.RedisSet(ctx, key, string(encoded), 5*time.Second) == nil +} + +func parseTokenProfiles(raw string) map[string]struct{} { + out := map[string]struct{}{} + value := strings.TrimSpace(raw) + if strings.HasPrefix(value, "TOKENS_V2:") { + var payload map[string]any + if json.Unmarshal([]byte(strings.TrimSpace(strings.TrimPrefix(value, "TOKENS_V2:"))), &payload) == nil { + stableEnabled := true + if rawEnabled, ok := payload["stable_enabled"]; ok { + stableEnabled = pythonTruthy(rawEnabled) + } else if rawEnabled, ok := payload["se"]; ok { + stableEnabled = pythonTruthy(rawEnabled) + } + stable := firstString(payload, "stable", "stable_token", "s") + if stableEnabled && buildTokenPattern.MatchString(stable) { + out[stable] = struct{}{} + } + update := firstString(payload, "new_update", "new update", "new_update_token", "n") + if buildTokenPattern.MatchString(update) { + out[update] = struct{}{} + } + } + return out + } + value = strings.ToUpper(value) + if buildTokenPattern.MatchString(value) { + out[value] = struct{}{} + } + return out +} + +func parseSignatureProfiles(raw string) map[string]struct{} { + out := map[string]struct{}{} + value := strings.TrimSpace(raw) + if strings.HasPrefix(value, "SIGS_V2:") { + var payload map[string]any + if json.Unmarshal([]byte(strings.TrimSpace(strings.TrimPrefix(value, "SIGS_V2:"))), &payload) == nil { + for _, key := range []string{"stable", "new_update", "new update"} { + for _, signature := range securityStringList(payload[key]) { + if normalized := normalizeSignature(signature); normalized != "" { + out[normalized] = struct{}{} + } + } + } + } + return out + } + for _, signature := range strings.Split(value, ",") { + if normalized := normalizeSignature(signature); normalized != "" { + out[normalized] = struct{}{} + } + } + return out +} + +func normalizeSignature(value string) string { + return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(value), ":", "")) +} + +func firstString(payload map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := payload[key].(string); ok { + value = strings.ToUpper(strings.TrimSpace(value)) + if value != "" { + return value + } + } + } + return "" +} + +func securityStringList(value any) []string { + switch typed := value.(type) { + case string: + return strings.Split(typed, ",") + case []any: + out := make([]string, 0, len(typed)) + for _, item := range typed { + if text, ok := item.(string); ok { + out = append(out, text) + } + } + return out + default: + return nil + } +} + +func startRequestURL(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" { + scheme = strings.TrimSpace(strings.Split(forwarded, ",")[0]) + } + host := r.Host + if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); forwarded != "" { + host = strings.TrimSpace(strings.Split(forwarded, ",")[0]) + } + return scheme + "://" + host + r.URL.RequestURI() +} + +func sebStartError(examID int, code, message string) *startHTTPError { + return &startHTTPError{Status: http.StatusForbidden, Detail: map[string]any{ + "error": code, + "message": message, + "download_config": "/api/exams/" + strconv.Itoa(examID) + "/seb-config.seb", + "mobile_launch_ios": "/api/exams/" + strconv.Itoa(examID) + "/seb-launch-mobile?platform=ios", + "mobile_launch_android": "/api/exams/" + strconv.Itoa(examID) + "/seb-launch-mobile?platform=android", + }} +} + +func abs64(value int64) int64 { + if value < 0 { + return -value + } + return value +} diff --git a/go/internal/exam/start_shuffle.go b/go/internal/exam/start_shuffle.go new file mode 100644 index 0000000..310ed48 --- /dev/null +++ b/go/internal/exam/start_shuffle.go @@ -0,0 +1,159 @@ +package exam + +import ( + "crypto/md5" + "crypto/sha256" + "encoding/binary" + "math/bits" + "reflect" + + "siab1/internal/persistence" +) + +const mtSize = 624 + +type pythonRandom struct { + mt [mtSize]uint32 + index int +} + +func newPythonRandom(seed string) *pythonRandom { + digest := md5.Sum([]byte(seed)) + key := []uint32{ + binary.BigEndian.Uint32(digest[12:16]), + binary.BigEndian.Uint32(digest[8:12]), + binary.BigEndian.Uint32(digest[4:8]), + binary.BigEndian.Uint32(digest[0:4]), + } + for len(key) > 1 && key[len(key)-1] == 0 { + key = key[:len(key)-1] + } + r := &pythonRandom{} + r.seedByArray(key) + return r +} + +func (r *pythonRandom) seedByArray(key []uint32) { + r.mt[0] = 19650218 + for i := 1; i < mtSize; i++ { + r.mt[i] = 1812433253*(r.mt[i-1]^(r.mt[i-1]>>30)) + uint32(i) + } + i, j := 1, 0 + loops := mtSize + if len(key) > loops { + loops = len(key) + } + for ; loops > 0; loops-- { + r.mt[i] = (r.mt[i] ^ ((r.mt[i-1] ^ (r.mt[i-1] >> 30)) * 1664525)) + key[j] + uint32(j) + i++ + j++ + if i >= mtSize { + r.mt[0] = r.mt[mtSize-1] + i = 1 + } + if j >= len(key) { + j = 0 + } + } + for loops = mtSize - 1; loops > 0; loops-- { + r.mt[i] = (r.mt[i] ^ ((r.mt[i-1] ^ (r.mt[i-1] >> 30)) * 1566083941)) - uint32(i) + i++ + if i >= mtSize { + r.mt[0] = r.mt[mtSize-1] + i = 1 + } + } + r.mt[0] = 0x80000000 + r.index = mtSize +} + +func (r *pythonRandom) uint32() uint32 { + if r.index >= mtSize { + for i := 0; i < mtSize; i++ { + y := (r.mt[i] & 0x80000000) | (r.mt[(i+1)%mtSize] & 0x7fffffff) + r.mt[i] = r.mt[(i+397)%mtSize] ^ (y >> 1) + if y&1 != 0 { + r.mt[i] ^= 0x9908b0df + } + } + r.index = 0 + } + y := r.mt[r.index] + r.index++ + y ^= y >> 11 + y ^= (y << 7) & 0x9d2c5680 + y ^= (y << 15) & 0xefc60000 + y ^= y >> 18 + return y +} + +func (r *pythonRandom) randBelow(n int) int { + if n <= 1 { + return 0 + } + k := bits.Len(uint(n)) + for { + candidate := int(r.uint32() >> (32 - k)) + if candidate < n { + return candidate + } + } +} + +func pythonShuffle[T any](items []T, seed string) { + if len(items) < 2 { + return + } + original := append([]T(nil), items...) + random := newPythonRandom(seed) + for i := len(items) - 1; i > 0; i-- { + j := random.randBelow(i + 1) + items[i], items[j] = items[j], items[i] + } + equal := true + for i := range items { + if !reflect.DeepEqual(items[i], original[i]) { + equal = false + break + } + } + if equal { + digest := md5.Sum([]byte(seed)) + mod := 0 + for _, value := range digest { + mod = (mod*256 + int(value)) % (len(items) - 1) + } + offset := mod + 1 + rotated := append(append([]T(nil), items[offset:]...), items[:offset]...) + copy(items, rotated) + } +} + +// Legacy preview uses these functions; native START uses pythonShuffle directly. +func shuffleQuestions(items []persistence.QuestionRow, seed string) { + rnd := seeded(seed) + for i := len(items) - 1; i > 0; i-- { + j := int(rnd.Uint32() % uint32(i+1)) + items[i], items[j] = items[j], items[i] + } +} + +func shuffleOptions(items []persistence.OptionRow, seed string) { + rnd := seeded(seed) + for i := len(items) - 1; i > 0; i-- { + j := int(rnd.Uint32() % uint32(i+1)) + items[i], items[j] = items[j], items[i] + } +} + +type rng struct{ s uint64 } + +func seeded(seed string) *rng { + sum := sha256.Sum256([]byte(seed)) + return &rng{s: binary.BigEndian.Uint64(sum[:8])} +} + +func (r *rng) Uint32() uint32 { + r.s = r.s*1664525 + 1013904223 + return uint32(r.s >> 32) +} diff --git a/go/internal/exam/start_shuffle_test.go b/go/internal/exam/start_shuffle_test.go new file mode 100644 index 0000000..e2affd0 --- /dev/null +++ b/go/internal/exam/start_shuffle_test.go @@ -0,0 +1,26 @@ +package exam + +import ( + "reflect" + "testing" +) + +func TestPythonShuffleMatchesFastAPISeeds(t *testing.T) { + fixture := loadStartParityFixture(t) + tests := []struct { + seed string + items []int + want []int + }{ + {"siab1_test_seed", []int{1, 2, 3, 4, 5}, fixture.StableShuffle}, + {"test-secret_42_9_question_11_options", []int{1, 2, 3, 4}, []int{2, 3, 1, 4}}, + {"test-secret_7_3_question_21_statements", []int{0, 1, 2}, []int{2, 0, 1}}, + } + for _, test := range tests { + items := append([]int(nil), test.items...) + pythonShuffle(items, test.seed) + if !reflect.DeepEqual(items, test.want) { + t.Fatalf("seed %q: got %v want %v", test.seed, items, test.want) + } + } +} diff --git a/go/internal/exam/submit.go b/go/internal/exam/submit.go deleted file mode 100644 index b5a85c5..0000000 --- a/go/internal/exam/submit.go +++ /dev/null @@ -1,7 +0,0 @@ -package exam - -import "net/http" - -func (d deps) submitExam(w http.ResponseWriter, r *http.Request) { - d.proxyExamWrite(w, r) -} diff --git a/go/internal/exam/submit_native.go b/go/internal/exam/submit_native.go new file mode 100644 index 0000000..1759a3e --- /dev/null +++ b/go/internal/exam/submit_native.go @@ -0,0 +1,327 @@ +package exam + +import ( + "context" + "encoding/json" + "errors" + "log" + "math" + "net/http" + "strconv" + "strings" + "time" + + "siab1/internal/auth" + "siab1/internal/persistence" +) + +type submitHTTPError struct { + Status int + Detail any + Headers map[string]string +} + +func (e *submitHTTPError) Error() string { return "submit http error" } + +func submitError(status int, detail any) *submitHTTPError { + return &submitHTTPError{Status: status, Detail: detail} +} + +type submitResponse struct { + SessionID int `json:"session_id"` + Status string `json:"status"` + Score *float64 `json:"score"` + TotalPoints *float64 `json:"total_points"` + PointsEarned *float64 `json:"points_earned"` + Percentage *float64 `json:"percentage"` + Passed *bool `json:"passed"` + Message string `json:"message"` +} + +func (d deps) submitExam(w http.ResponseWriter, r *http.Request) { + if d.store == nil || !d.store.HasPool() { + writeDetail(w, http.StatusServiceUnavailable, "Database tidak tersedia") + return + } + response, err := d.acceptSubmit(r) + if err != nil { + if errors.Is(r.Context().Err(), context.Canceled) { + return + } + if err.Status == http.StatusUnauthorized { + w.Header().Set("WWW-Authenticate", "Bearer") + } + for key, value := range err.Headers { + w.Header().Set(key, value) + } + writeJSON(w, err.Status, map[string]any{"detail": err.Detail}) + return + } + writeJSON(w, http.StatusOK, response) +} + +func (d deps) acceptSubmit(r *http.Request) (*submitResponse, *submitHTTPError) { + raw := auth.Bearer(r.Header.Get("Authorization")) + if raw == "" { + return nil, submitError(http.StatusUnauthorized, "Not authenticated") + } + claims, err := auth.Parse(d.secret, raw) + if err != nil { + return nil, submitError(http.StatusUnauthorized, "Token tidak valid atau sudah kadaluarsa") + } + userID, err := claims.UserID() + if err != nil { + return nil, submitError(http.StatusUnauthorized, "Token tidak valid atau sudah kadaluarsa") + } + if !claims.Active() { + return nil, submitError(http.StatusForbidden, "Akun tidak aktif") + } + var body map[string]any + if readErr := readJSON(r, &body); readErr != nil { + return nil, submitError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + sessionID, ok := coerceSubmitInt(body["session_id"]) + if !ok { + return nil, submitError(http.StatusUnprocessableEntity, "Payload tidak valid") + } + forceSubmit := pythonBool(body["force_submit"]) + if !d.disableRateLimit { + allowed, remaining := d.store.AllowSlidingRate(r.Context(), "exam_submit", strconv.Itoa(userID), 10, 60) + if !allowed { + return nil, &submitHTTPError{ + Status: http.StatusTooManyRequests, + Detail: "Terlalu banyak percobaan submit. Tunggu beberapa saat.", + Headers: map[string]string{ + "Retry-After": "20", + "X-RateLimit-Remaining": strconv.Itoa(remaining), + }, + } + } + } + probe, probeErr := d.store.LoadSubmitSession(r.Context(), sessionID, userID) + if probeErr != nil { + if persistence.IsTransientDB(probeErr) { + return nil, busySubmit() + } + return nil, submitError(http.StatusInternalServerError, "Gagal memuat sesi") + } + if probe == nil { + return nil, submitError(http.StatusNotFound, "Sesi ujian tidak ditemukan") + } + status := strings.ToLower(strings.TrimSpace(probe.Status)) + if status == "submitted" || status == "completed" { + return alreadySubmittedResponse(*probe), nil + } + if status != "in_progress" { + return nil, submitError(http.StatusBadRequest, "Sesi ujian sudah berakhir") + } + settings, settingsErr := d.store.LoadStartSecuritySettings(r.Context()) + if settingsErr != nil { + return nil, submitError(http.StatusInternalServerError, "Internal Server Error") + } + if sebErr := validateStartSEB( + r.Context(), d.store, r, probe.ExamID, settings, d.sebKey, d.sebChallenge, d.sebChallengePrefix, + ); sebErr != nil { + return nil, submitError(sebErr.Status, sebErr.Detail) + } + submittedAt := time.Now().UTC() + result, finErr := d.store.FinalizeNativeSubmit(r.Context(), sessionID, userID, forceSubmit, submittedAt, gradeSubmitSession) + if finErr != nil { + if persistence.IsTransientDB(finErr) { + return nil, busySubmit() + } + log.Printf("go_submit finalize failed session=%d err=%v", sessionID, finErr) + return nil, submitError(http.StatusInternalServerError, "Gagal mengumpulkan ujian") + } + if result.Status == "not_found" { + return nil, submitError(http.StatusNotFound, "Sesi ujian tidak ditemukan") + } + if result.Status == "already" { + return alreadySubmittedResponse(result.Row), nil + } + if result.Status == "ended" { + return nil, submitError(http.StatusBadRequest, "Sesi ujian sudah berakhir") + } + _ = d.store.PatchSubmittedSnapshot( + r.Context(), result.Row.ID, userID, result.Row.ExamID, result.Row.ViolationCount, result.Row.EndTime, + ) + monitor, _ := json.Marshal(map[string]any{ + "type": "student_submitted", + "user_id": userID, + "username": claims.Username, + "session_id": result.Row.ID, + "score": result.Percentage, + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + }) + _ = d.store.RedisPublish(r.Context(), "exam_monitor_"+strconv.Itoa(result.Row.ExamID), string(monitor)) + if d.monitoringDelta { + _ = d.store.RedisXAdd(r.Context(), "exam_monitor_delta:"+strconv.Itoa(result.Row.ExamID), string(monitor), d.monitoringDeltaMaxLen, d.monitoringDeltaTTL) + } + show := result.Row.ShowResults + resp := &submitResponse{ + SessionID: result.Row.ID, + Status: "submitted", + Message: "Ujian berhasil dikumpulkan", + } + if forceSubmit { + resp.Message = "Ujian dikumpulkan otomatis karena pelanggaran" + } + if show { + score := result.Percentage + total := result.TotalPoints + earned := result.PointsEarned + resp.Score = &score + resp.TotalPoints = &total + resp.PointsEarned = &earned + resp.Percentage = &score + if result.Row.PassingScore != nil && *result.Row.PassingScore != 0 { + passed := result.Percentage >= *result.Row.PassingScore + resp.Passed = &passed + } + } + return resp, nil +} + +func alreadySubmittedResponse(row persistence.SubmitSessionRow) *submitResponse { + resp := &submitResponse{ + SessionID: row.ID, + Status: "submitted", + Message: "Sesi sudah pernah dikumpulkan.", + } + if row.ShowResults && row.Score != nil { + score := *row.Score + resp.Score = &score + resp.Percentage = &score + if row.PassingScore != nil && *row.PassingScore != 0 { + passed := score >= *row.PassingScore + resp.Passed = &passed + } + } + return resp +} + +func busySubmit() *submitHTTPError { + return &submitHTTPError{ + Status: http.StatusServiceUnavailable, + Detail: "Server sedang sibuk, silakan ulangi submit.", + Headers: map[string]string{"Retry-After": "1"}, + } +} + +func gradeSubmitSession(questions []persistence.QuestionRow, answers []persistence.SubmitAnswerGrade) persistence.SubmitGradeOutput { + latest := map[int]persistence.SubmitAnswerGrade{} + for _, answer := range answers { + cur, ok := latest[answer.QuestionID] + if !ok { + latest[answer.QuestionID] = answer + continue + } + if submitAnswerNewer(answer, cur) { + latest[answer.QuestionID] = answer + } + } + qmap := map[int]persistence.QuestionRow{} + total := 0.0 + for _, question := range questions { + qmap[question.ID] = question + total += question.Points + } + out := persistence.SubmitGradeOutput{TotalPoints: total, Breakdown: []map[string]any{}} + earned := 0.0 + for _, answer := range latest { + question, ok := qmap[answer.QuestionID] + if !ok { + continue + } + isCorrect, points := scoreSubmitAnswer(question, answer) + out.Scores = append(out.Scores, persistence.SubmitAnswerScore{ + QuestionID: question.ID, + IsCorrect: isCorrect, + Points: points, + }) + var earnedVal any + if points != nil { + earned += *points + earnedVal = *points + } + partial := settingBool(question.Settings, "partial_scoring") + out.Breakdown = append(out.Breakdown, map[string]any{ + "question_id": strconv.Itoa(question.ID), + "question_type": question.Type, + "points_earned": earnedVal, + "max_points": question.Points, + "is_correct": isCorrect, + "partial_scoring": partial, + }) + } + out.PointsEarned = earned + percentage := 0.0 + if total > 0 { + percentage = earned / total * 100 + } + out.Percentage = round2(percentage) + return out +} + +func scoreSubmitAnswer(question persistence.QuestionRow, answer persistence.SubmitAnswerGrade) (*bool, *float64) { + if canReuseSubmitScore(question, answer) { + return answer.IsCorrect, answer.Points + } + row := persistence.AnswerRow{ + QuestionID: answer.QuestionID, + SelectedOptionID: answer.SelectedOptionID, + SelectedOptionIDs: answer.SelectedOptionIDs, + AnswerText: answer.AnswerText, + Metadata: answer.Metadata, + IsCorrect: answer.IsCorrect, + Points: answer.Points, + AnsweredAt: answer.AnsweredAt, + } + isCorrect, points := gradeAnswer(question, row) + if question.Type == "essay" || question.Type == "short_answer" { + manual := settingBool(question.Settings, "require_manual_grading") + acceptable := settingStringSlice(question.Settings, "acceptable_answers") + if manual || question.Type == "essay" || len(acceptable) == 0 { + return nil, nil + } + } + return isCorrect, points +} + +func canReuseSubmitScore(question persistence.QuestionRow, answer persistence.SubmitAnswerGrade) bool { + if answer.Points == nil { + return false + } + switch question.Type { + case "multiple_choice", "true_false", "multiple_choice_complex": + return answer.IsCorrect != nil + case "short_answer": + manual := settingBool(question.Settings, "require_manual_grading") + acceptable := settingStringSlice(question.Settings, "acceptable_answers") + return !manual && len(acceptable) > 0 && answer.IsCorrect != nil + default: + return false + } +} + +func submitAnswerNewer(a, b persistence.SubmitAnswerGrade) bool { + at := time.Time{} + bt := time.Time{} + if a.AnsweredAt != nil { + at = a.AnsweredAt.UTC() + } + if b.AnsweredAt != nil { + bt = b.AnsweredAt.UTC() + } + if at.After(bt) { + return true + } + if at.Equal(bt) { + return a.ID >= b.ID + } + return false +} + +func round2(v float64) float64 { + return math.Round(v*100) / 100 +} diff --git a/go/internal/exam/testdata/fastapi_start_parity.json b/go/internal/exam/testdata/fastapi_start_parity.json new file mode 100644 index 0000000..ce4638a --- /dev/null +++ b/go/internal/exam/testdata/fastapi_start_parity.json @@ -0,0 +1,27 @@ +{ + "stable_shuffle": [5, 1, 2, 3, 4], + "question_order": [12, 13, 11, 14], + "option_order_question_11": [2, 3, 1, 4], + "table_statement_order": [2, 0, 1], + "image_placeholder_text": "Perhatikan gambar soal berikut, lalu pilih jawaban yang benar.", + "image_placeholder_option_order": [1, 2, 3, 4], + "question_keys": [ + "id", "question_text", "stimulus", "question_type", "pgk_type", + "difficulty_level", "category", "tags", "question_settings", "points", + "order_index", "image_url", "video_url", "audio_url", "options" + ], + "option_keys": ["id", "option_text", "order_index", "option_group", "pair_id"], + "response_keys": [ + "session_id", "exam_id", "exam_title", "duration_minutes", "question_count", + "start_time", "end_time", "server_time", "show_results", "show_teacher_name", + "teacher_name", "subject", "exam_type", "shuffle_questions", "shuffle_options", + "session_poll_token", "session_poll_token_expires_minutes", "questions" + ], + "difficulty_default": "medium", + "points": { + "int_like": "1.0", + "from_1_00": "1.0", + "fractional": "1.25", + "zero": "0.0" + } +} diff --git a/go/internal/httpserver/hotpath_test.go b/go/internal/httpserver/hotpath_test.go index d18bb7b..a037560 100644 --- a/go/internal/httpserver/hotpath_test.go +++ b/go/internal/httpserver/hotpath_test.go @@ -86,6 +86,31 @@ func TestStartExamRequiresAuth(t *testing.T) { } } +func TestReplicaHeaderAndAdmissionStatus(t *testing.T) { + h := httpserver.New(config.Config{ + DisableRateLimit: true, + JWTSecretKey: "test-secret", + SIABReplica: "go-start", + StartDBAdmissionLimit: 4, + }, nil) + req := httptest.NewRequest(http.MethodGet, "/internal/start-admission", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if rec.Header().Get("X-SIAB-Replica") != "go-start" { + t.Fatalf("replica=%q", rec.Header().Get("X-SIAB-Replica")) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["limit"] != float64(4) || body["holders"] != float64(0) { + t.Fatalf("admission=%v", body) + } +} + func TestMeRequiresAuth(t *testing.T) { h := httpserver.New(config.Config{DisableRateLimit: true, JWTSecretKey: "test-secret"}, nil) req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) @@ -467,7 +492,7 @@ func TestExamWebSocketRequiresUpgrade(t *testing.T) { } } -func TestExamWritesProxyToPythonUpstream(t *testing.T) { +func TestNonStartExamWritesProxyToPythonUpstream(t *testing.T) { hits := map[string]int{} up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits[r.Method+" "+r.URL.Path]++ @@ -490,11 +515,6 @@ func TestExamWritesProxyToPythonUpstream(t *testing.T) { path string body string }{ - {http.MethodPost, "/api/exams/auto-save", `{"session_id":1,"answers":{}}`}, - {http.MethodPost, "/api/exams/submit-answer", `{"session_id":1,"question_id":2}`}, - {http.MethodPost, "/api/exams/1/start", `{}`}, - {http.MethodPost, "/api/exams/submit", `{"session_id":1}`}, - {http.MethodPost, "/api/exams/auto-save-batch", `{"session_id":1,"answers":[]}`}, {http.MethodPost, "/api/exams/answer-journal/sync", `{"session_id":1,"events":[]}`}, {http.MethodPost, "/api/exams/log-violation", `{"session_id":1,"event_type":"tab_switch"}`}, } diff --git a/go/internal/httpserver/server.go b/go/internal/httpserver/server.go index 1604c36..a81d6fd 100644 --- a/go/internal/httpserver/server.go +++ b/go/internal/httpserver/server.go @@ -75,7 +75,7 @@ func New(cfg config.Config, store *persistence.Store) http.Handler { if !cfg.DisableRateLimit { h = security.RateLimit(h) } - h = security.Headers(h) + h = security.Headers(h, cfg.SIABReplica) h = security.CORS(cfg.CORSOrigins)(h) audit.Record("httpserver_ready", "") return h diff --git a/go/internal/persistence/answer_native.go b/go/internal/persistence/answer_native.go new file mode 100644 index 0000000..8d1a22f --- /dev/null +++ b/go/internal/persistence/answer_native.go @@ -0,0 +1,373 @@ +package persistence + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/redis/go-redis/v9" +) + + + +type AnswerSessionProbe struct { + ID int + ExamID int + Status string +} + +type AnswerQuestionPayload struct { + ID int + ExamID int + QuestionType string + PGKType *string + Points float64 + QuestionSettings []byte + Options []AnswerQuestionOption +} + +type AnswerQuestionOption struct { + ID int + IsCorrect bool +} + +type AnswerWriteFields struct { + SelectedOptionID *int + SelectedOptionIDs []int32 + AnswerText *string + Metadata []byte + IsCorrect *bool + PointsEarned *float64 + AnsweredAt time.Time +} + +func (s *Store) HasRedis() bool { + return s != nil && s.redis != nil +} + +func (s *Store) ProbeAnswerSession(ctx context.Context, sessionID, userID int) (*AnswerSessionProbe, error) { + if !s.HasPool() { + return nil, fmt.Errorf("no pgx pool") + } + var row AnswerSessionProbe + err := s.pool.QueryRow(ctx, ` +SELECT id, exam_id, status + FROM exam_sessions + WHERE id = $1 AND user_id = $2`, sessionID, userID).Scan(&row.ID, &row.ExamID, &row.Status) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +func (s *Store) LoadAnswerQuestion(ctx context.Context, examID, questionID int) (*AnswerQuestionPayload, error) { + if !s.HasPool() { + return nil, fmt.Errorf("no pgx pool") + } + var row AnswerQuestionPayload + err := s.pool.QueryRow(ctx, ` +SELECT id, exam_id, question_type, pgk_type, COALESCE(points, 0), COALESCE(question_settings, '{}'::jsonb) + FROM questions + WHERE id = $1 AND exam_id = $2`, questionID, examID).Scan( + &row.ID, &row.ExamID, &row.QuestionType, &row.PGKType, &row.Points, &row.QuestionSettings, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + settings := map[string]any{} + _ = json.Unmarshal(row.QuestionSettings, &settings) + pgk := "" + if row.PGKType != nil { + pgk = strings.TrimSpace(*row.PGKType) + } + if pgk == "" { + if raw, ok := settings["pgk_type"].(string); ok { + pgk = strings.TrimSpace(raw) + } + } + if pgk == "" { + pgk = "checkbox" + } + needsOptions := row.QuestionType == "multiple_choice" || row.QuestionType == "true_false" || + (row.QuestionType == "multiple_choice_complex" && pgk != "table_validation") + if needsOptions { + optRows, optErr := s.pool.Query(ctx, ` +SELECT id, COALESCE(is_correct, false) + FROM question_options + WHERE question_id = $1`, row.ID) + if optErr != nil { + return nil, optErr + } + defer optRows.Close() + for optRows.Next() { + var opt AnswerQuestionOption + if scanErr := optRows.Scan(&opt.ID, &opt.IsCorrect); scanErr != nil { + return nil, scanErr + } + row.Options = append(row.Options, opt) + } + if err := optRows.Err(); err != nil { + return nil, err + } + } + return &row, nil +} + +func (s *Store) WriteSingleAnswerDirect( + ctx context.Context, + sessionID, userID, questionID int, + fields AnswerWriteFields, +) (string, error) { + if !s.HasPool() { + return "", fmt.Errorf("no pgx pool") + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return "", err + } + defer func() { _ = tx.Rollback(ctx) }() + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1, $2)`, sessionWriteLockNS, sessionID); err != nil { + return "", err + } + var status string + err = tx.QueryRow(ctx, ` +SELECT status FROM exam_sessions + WHERE id = $1 AND user_id = $2 + FOR UPDATE`, sessionID, userID).Scan(&status) + if errors.Is(err, pgx.ErrNoRows) { + return "", errAnswerNotFound + } + if err != nil { + return "", err + } + normalized := strings.ToLower(strings.TrimSpace(status)) + if normalized == "submitted" || normalized == "completed" { + if err := tx.Commit(ctx); err != nil { + return "", err + } + return "submitted", nil + } + if normalized != "in_progress" { + return normalized, errAnswerEnded + } + if len(fields.Metadata) == 0 { + fields.Metadata = []byte("{}") + } + _, err = tx.Exec(ctx, ` +INSERT INTO answers ( + session_id, question_id, selected_option_id, selected_option_ids, + answer_text, answer_metadata, is_correct, points_earned, answered_at +) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9) +ON CONFLICT (session_id, question_id) DO UPDATE SET + selected_option_id = EXCLUDED.selected_option_id, + selected_option_ids = EXCLUDED.selected_option_ids, + answer_text = EXCLUDED.answer_text, + answer_metadata = EXCLUDED.answer_metadata, + is_correct = EXCLUDED.is_correct, + points_earned = EXCLUDED.points_earned, + answered_at = EXCLUDED.answered_at +WHERE answers.selected_option_id IS DISTINCT FROM EXCLUDED.selected_option_id + OR answers.selected_option_ids IS DISTINCT FROM EXCLUDED.selected_option_ids + OR answers.answer_text IS DISTINCT FROM EXCLUDED.answer_text + OR answers.answer_metadata IS DISTINCT FROM EXCLUDED.answer_metadata + OR answers.is_correct IS DISTINCT FROM EXCLUDED.is_correct + OR answers.points_earned IS DISTINCT FROM EXCLUDED.points_earned`, + sessionID, questionID, fields.SelectedOptionID, fields.SelectedOptionIDs, + fields.AnswerText, string(fields.Metadata), fields.IsCorrect, fields.PointsEarned, fields.AnsweredAt, + ) + if err != nil && strings.Contains(strings.ToLower(err.Error()), "no unique or exclusion constraint") { + if _, lockErr := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1, $2)`, sessionID, questionID); lockErr != nil { + return "", lockErr + } + tag, updErr := tx.Exec(ctx, ` +UPDATE answers SET + selected_option_id = $3, + selected_option_ids = $4, + answer_text = $5, + answer_metadata = $6::jsonb, + is_correct = $7, + points_earned = $8, + answered_at = $9 + WHERE session_id = $1 AND question_id = $2`, + sessionID, questionID, fields.SelectedOptionID, fields.SelectedOptionIDs, + fields.AnswerText, string(fields.Metadata), fields.IsCorrect, fields.PointsEarned, fields.AnsweredAt, + ) + if updErr != nil { + return "", updErr + } + if tag.RowsAffected() == 0 { + _, err = tx.Exec(ctx, ` +INSERT INTO answers ( + session_id, question_id, selected_option_id, selected_option_ids, + answer_text, answer_metadata, is_correct, points_earned, answered_at +) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9)`, + sessionID, questionID, fields.SelectedOptionID, fields.SelectedOptionIDs, + fields.AnswerText, string(fields.Metadata), fields.IsCorrect, fields.PointsEarned, fields.AnsweredAt, + ) + } else { + err = nil + } + } + if err != nil { + return "", err + } + if err := tx.Commit(ctx); err != nil { + return "", err + } + return "in_progress", nil +} + +var ( + errAnswerNotFound = errors.New("answer session not found") + errAnswerEnded = errors.New("answer session ended") +) + +func IsAnswerNotFound(err error) bool { return errors.Is(err, errAnswerNotFound) } +func IsAnswerEnded(err error) bool { return errors.Is(err, errAnswerEnded) } + +func (s *Store) AllowSlidingRate(ctx context.Context, prefix, identifier string, limit, window int) (bool, int) { + if !s.HasRedis() { + return true, limit + } + key := "ratelimit:" + prefix + ":" + identifier + now := float64(time.Now().UnixNano()) / 1e9 + windowStart := now - float64(window) + pipe := s.redis.Pipeline() + pipe.ZRemRangeByScore(ctx, key, "0", strconv.FormatFloat(windowStart, 'f', -1, 64)) + countCmd := pipe.ZCard(ctx, key) + member := fmt.Sprintf("%.6f:%d", now, time.Now().UnixNano()) + pipe.ZAdd(ctx, key, redis.Z{Score: now, Member: member}) + pipe.Expire(ctx, key, time.Duration(window+1)*time.Second) + _, err := pipe.Exec(ctx) + if err != nil { + return true, limit + } + current := int(countCmd.Val()) + remaining := limit - current - 1 + if remaining < 0 { + remaining = 0 + } + return current < limit, remaining +} + +func (s *Store) AddAnsweredQuestions(ctx context.Context, sessionID int, questionIDs []int) (int, bool, error) { + if !s.HasRedis() { + return 0, false, fmt.Errorf("no redis client") + } + members := make([]any, 0, len(questionIDs)) + seen := map[int]struct{}{} + for _, id := range questionIDs { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + members = append(members, strconv.Itoa(id)) + } + if len(members) == 0 { + return 0, false, nil + } + key := fmt.Sprintf("exam_answered_questions:%d", sessionID) + pipe := s.redis.Pipeline() + pipe.SAdd(ctx, key, members...) + pipe.Expire(ctx, key, 7200*time.Second) + card := pipe.SCard(ctx, key) + if _, err := pipe.Exec(ctx); err != nil { + return 0, false, err + } + return int(card.Val()), true, nil +} + +func (s *Store) PatchSessionAnsweredCount(ctx context.Context, sessionID, userID, count int) error { + if !s.HasRedis() { + return fmt.Errorf("no redis client") + } + key := fmt.Sprintf("exam_session:%d", sessionID) + raw, err := s.redis.Get(ctx, key).Result() + if errors.Is(err, redis.Nil) { + return nil + } + if err != nil { + return err + } + var snapshot map[string]any + if json.Unmarshal([]byte(raw), &snapshot) != nil { + return nil + } + if snapshotUser, ok := asInt(snapshot["user_id"]); ok && snapshotUser != userID { + return nil + } + if count < 0 { + count = 0 + } + snapshot["answered_count"] = count + snapshot["answered_count_stale"] = false + snapshot["status"] = "in_progress" + encoded, err := json.Marshal(snapshot) + if err != nil { + return err + } + return s.redis.Set(ctx, key, encoded, 7200*time.Second).Err() +} + +func (s *Store) ReplaceSessionAnswerCache(ctx context.Context, sessionID int, payload any) error { + if !s.HasRedis() { + return fmt.Errorf("no redis client") + } + encoded, err := json.Marshal(payload) + if err != nil { + return err + } + return s.redis.Set(ctx, fmt.Sprintf("exam_answers:%d", sessionID), encoded, 7200*time.Second).Err() +} + +func asInt(v any) (int, bool) { + switch typed := v.(type) { + case float64: + return int(typed), true + case json.Number: + n, err := typed.Int64() + return int(n), err == nil + case int: + return typed, true + case string: + n, err := strconv.Atoi(typed) + return n, err == nil + default: + return 0, false + } +} + +func IsTransientDB(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + markers := []string{ + "timeout", + "queuepool limit", + "connection was closed", + "too many clients already", + "canceling statement due to statement timeout", + "could not serialize access due to concurrent update", + } + for _, marker := range markers { + if strings.Contains(message, marker) { + return true + } + } + return false +} diff --git a/go/internal/persistence/autosave_batch.go b/go/internal/persistence/autosave_batch.go new file mode 100644 index 0000000..0785ddc --- /dev/null +++ b/go/internal/persistence/autosave_batch.go @@ -0,0 +1,338 @@ +package persistence + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +type BatchAnswerWrite struct { + QuestionID int + SelectedOptionID *int + SelectedOptionIDs []int32 + HasOptionIDs bool + AnswerText *string + IncomingMetadata map[string]any + StatementAnswers map[string]bool + HasStatements bool +} + +type BatchWriteOutcome struct { + Changed int + ValidCount int + Status string +} + +func (s *Store) ValidQuestionIDs(ctx context.Context, examID int, questionIDs []int) (map[int]struct{}, error) { + out := map[int]struct{}{} + if !s.HasPool() { + return nil, fmt.Errorf("no pgx pool") + } + if len(questionIDs) == 0 { + return out, nil + } + rows, err := s.pool.Query(ctx, `SELECT id FROM questions WHERE exam_id = $1 AND id = ANY($2)`, examID, questionIDs) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + return nil, err + } + out[id] = struct{}{} + } + return out, rows.Err() +} + +func (s *Store) WriteBatchAutosave( + ctx context.Context, + sessionID, userID int, + items []BatchAnswerWrite, + now time.Time, +) (BatchWriteOutcome, error) { + out := BatchWriteOutcome{ValidCount: len(items), Status: "no_changes"} + if !s.HasPool() { + return out, fmt.Errorf("no pgx pool") + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return out, err + } + defer func() { _ = tx.Rollback(ctx) }() + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1, $2)`, sessionWriteLockNS, sessionID); err != nil { + return out, err + } + var status string + err = tx.QueryRow(ctx, ` +SELECT status FROM exam_sessions + WHERE id = $1 AND user_id = $2 + FOR UPDATE`, sessionID, userID).Scan(&status) + if errors.Is(err, pgx.ErrNoRows) { + return BatchWriteOutcome{Status: "not_found"}, nil + } + if err != nil { + return out, err + } + if strings.ToLower(strings.TrimSpace(status)) != "in_progress" { + return BatchWriteOutcome{Status: "ended"}, nil + } + ids := make([]int, 0, len(items)) + for _, item := range items { + ids = append(ids, item.QuestionID) + } + existing := map[int]existingBatchAnswer{} + if len(ids) > 0 { + rows, qerr := tx.Query(ctx, ` +SELECT question_id, selected_option_id, selected_option_ids, answer_text, COALESCE(answer_metadata, '{}'::jsonb) + FROM answers + WHERE session_id = $1 AND question_id = ANY($2)`, sessionID, ids) + if qerr != nil { + return out, qerr + } + for rows.Next() { + var row existingBatchAnswer + if scanErr := rows.Scan(&row.QuestionID, &row.SelectedOptionID, &row.SelectedOptionIDs, &row.AnswerText, &row.Metadata); scanErr != nil { + rows.Close() + return out, scanErr + } + existing[row.QuestionID] = row + } + rows.Close() + if err := rows.Err(); err != nil { + return out, err + } + } + changed := 0 + for _, item := range items { + cur, ok := existing[item.QuestionID] + existingMeta := map[string]any{} + if ok && len(cur.Metadata) > 0 { + _ = json.Unmarshal(cur.Metadata, &existingMeta) + } + var statements map[string]bool + if item.HasStatements { + statements = item.StatementAnswers + if statements == nil { + statements = map[string]bool{} + } + } + finalMeta := MergeStatementMetadata(existingMeta, item.IncomingMetadata, statements) + meta := MetadataJSON(finalMeta) + if ok && !batchAnswerChanged(cur, item, meta) { + continue + } + var optionIDs any + if item.HasOptionIDs { + optionIDs = item.SelectedOptionIDs + } + if ok { + if _, err := tx.Exec(ctx, ` +UPDATE answers SET + selected_option_id = $3, + selected_option_ids = $4, + answer_text = $5, + answer_metadata = $6::jsonb, + answered_at = $7, + is_correct = NULL, + points_earned = NULL + WHERE session_id = $1 AND question_id = $2`, + sessionID, item.QuestionID, item.SelectedOptionID, optionIDs, item.AnswerText, string(meta), now, + ); err != nil { + return out, err + } + } else { + if _, err := tx.Exec(ctx, ` +INSERT INTO answers ( + session_id, question_id, selected_option_id, selected_option_ids, + answer_text, answer_metadata, answered_at, is_correct, points_earned +) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,NULL,NULL)`, + sessionID, item.QuestionID, item.SelectedOptionID, optionIDs, item.AnswerText, string(meta), now, + ); err != nil { + return out, err + } + } + changed++ + } + if err := tx.Commit(ctx); err != nil { + return out, err + } + out.Changed = changed + if changed > 0 { + out.Status = "saved_to_db" + } + return out, nil +} + +type existingBatchAnswer struct { + QuestionID int + SelectedOptionID *int + SelectedOptionIDs []int32 + AnswerText *string + Metadata []byte +} + +func batchAnswerChanged(cur existingBatchAnswer, item BatchAnswerWrite, meta []byte) bool { + if (cur.SelectedOptionID == nil) != (item.SelectedOptionID == nil) { + return true + } + if cur.SelectedOptionID != nil && item.SelectedOptionID != nil && *cur.SelectedOptionID != *item.SelectedOptionID { + return true + } + if !pyInt32Equal(cur.SelectedOptionIDs, item.SelectedOptionIDs, cur.SelectedOptionIDs != nil, item.HasOptionIDs) { + return true + } + if (cur.AnswerText == nil) != (item.AnswerText == nil) { + return true + } + if cur.AnswerText != nil && item.AnswerText != nil && *cur.AnswerText != *item.AnswerText { + return true + } + return !jsonBytesEqual(cur.Metadata, meta) +} + +func pyInt32Equal(a, b []int32, aSet, bSet bool) bool { + if !aSet && !bSet { + return true + } + if !aSet || !bSet { + return false + } + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func MergeStatementMetadata(existing, incoming map[string]any, statements map[string]bool) map[string]any { + previous := map[string]any{} + for key, value := range existing { + previous[key] = value + } + normalized := map[string]any{} + for key, value := range incoming { + normalized[key] = value + } + var prevStatements map[string]bool + switch raw := previous["statement_answers"].(type) { + case map[string]any: + prevStatements = map[string]bool{} + for key, value := range raw { + prevStatements[key] = asBool(value) + } + case map[string]bool: + prevStatements = raw + } + replace := pyBool(normalized["replace_statement_answers"]) + deleteStmts := pyBool(normalized["delete_statement_answers"]) + var merged map[string]bool + if deleteStmts { + merged = map[string]bool{} + } else if statements == nil { + if replace { + merged = map[string]bool{} + } else if prevStatements != nil { + merged = prevStatements + } + } else if replace { + merged = statements + } else if prevStatements != nil { + merged = map[string]bool{} + for key, value := range prevStatements { + merged[key] = value + } + for key, value := range statements { + merged[key] = value + } + } else { + merged = statements + } + final := map[string]any{} + for key, value := range previous { + final[key] = value + } + for key, value := range normalized { + final[key] = value + } + delete(final, "replace_statement_answers") + delete(final, "delete_statement_answers") + if merged != nil { + if len(merged) > 0 { + final["statement_answers"] = merged + } else { + delete(final, "statement_answers") + } + } + return final +} + +func pyBool(v any) bool { + switch typed := v.(type) { + case nil: + return false + case bool: + return typed + case string: + return typed != "" + case float64: + return typed != 0 + case int: + return typed != 0 + case json.Number: + n, _ := typed.Float64() + return n != 0 + case map[string]any: + return len(typed) > 0 + case []any: + return len(typed) > 0 + default: + return true + } +} + +func asBool(v any) bool { + switch typed := v.(type) { + case bool: + return typed + case string: + lower := strings.ToLower(strings.TrimSpace(typed)) + return lower == "true" || lower == "1" || lower == "yes" + case float64: + return typed != 0 + case int: + return typed != 0 + case json.Number: + n, _ := typed.Float64() + return n != 0 + default: + return pyBool(v) + } +} + +func jsonBytesEqual(a, b []byte) bool { + if len(a) == 0 { + a = []byte("{}") + } + if len(b) == 0 { + b = []byte("{}") + } + var am, bm any + if json.Unmarshal(a, &am) != nil || json.Unmarshal(b, &bm) != nil { + return bytes.Equal(a, b) + } + ab, _ := json.Marshal(am) + bb, _ := json.Marshal(bm) + return bytes.Equal(ab, bb) +} diff --git a/go/internal/persistence/exam.go b/go/internal/persistence/exam.go index f6562b7..08c00d5 100644 --- a/go/internal/persistence/exam.go +++ b/go/internal/persistence/exam.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "regexp" + "strconv" "strings" "time" @@ -115,31 +116,32 @@ type SessionRow struct { } type QuestionRow struct { - ID int - ExamID int - Text string - Stimulus *string - Type string - PgkType *string - Difficulty string - Settings []byte - Points float64 - OrderIndex int - ImageURL *string - VideoURL *string - AudioURL *string + ID int `json:"id"` + ExamID int `json:"-"` + Text string `json:"question_text"` + Stimulus *string `json:"stimulus"` + Type string `json:"question_type"` + PgkType *string `json:"pgk_type"` + Difficulty string `json:"difficulty_level,omitempty"` + Settings []byte `json:"question_settings"` + Points float64 `json:"-"` + PointsText string `json:"points"` + OrderIndex int `json:"order_index"` + ImageURL *string `json:"image_url"` + VideoURL *string `json:"video_url"` + AudioURL *string `json:"audio_url"` CategoryID *int - Options []OptionRow + Options []OptionRow `json:"options"` } type OptionRow struct { - ID int - QuestionID int - Text string - OrderIndex int - OptionGroup string - PairID *string - IsCorrect bool + ID int `json:"id"` + QuestionID int `json:"-"` + Text string `json:"option_text"` + OrderIndex int `json:"order_index"` + OptionGroup string `json:"option_group"` + PairID *string `json:"pair_id"` + IsCorrect bool `json:"-"` } func (s *Store) GetUser(ctx context.Context, id int) (*UserRow, error) { @@ -557,7 +559,7 @@ func (s *Store) loadQuestions(ctx context.Context, examID int, withKeys bool) ([ qrows, err := s.pool.Query(ctx, ` SELECT id, question_text, stimulus, question_type, pgk_type, COALESCE(difficulty_level, 'medium'), COALESCE(question_settings, '{}'::jsonb), - COALESCE(points, 1), order_index, image_url, video_url, audio_url + COALESCE(points, 1)::text, order_index, image_url, video_url, audio_url FROM questions WHERE exam_id = $1 ORDER BY order_index, id`, examID) if err != nil { return nil, err @@ -569,10 +571,11 @@ SELECT id, question_text, stimulus, question_type, pgk_type, var q QuestionRow if err := qrows.Scan( &q.ID, &q.Text, &q.Stimulus, &q.Type, &q.PgkType, &q.Difficulty, - &q.Settings, &q.Points, &q.OrderIndex, &q.ImageURL, &q.VideoURL, &q.AudioURL, + &q.Settings, &q.PointsText, &q.OrderIndex, &q.ImageURL, &q.VideoURL, &q.AudioURL, ); err != nil { return nil, err } + q.Points, _ = strconv.ParseFloat(q.PointsText, 64) questions = append(questions, q) ids = append(ids, q.ID) } diff --git a/go/internal/persistence/join_native.go b/go/internal/persistence/join_native.go new file mode 100644 index 0000000..fc17cd9 --- /dev/null +++ b/go/internal/persistence/join_native.go @@ -0,0 +1,92 @@ +package persistence + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +type JoinUserRow struct { + ID int + Role string + StudentClass *string + IsActive bool +} + +func (s *Store) LookupJoinUser(ctx context.Context, userID int) (*JoinUserRow, error) { + if !s.HasPool() { + return nil, fmt.Errorf("no pgx pool") + } + var row JoinUserRow + err := s.pool.QueryRow(ctx, ` +SELECT id, role, student_class, COALESCE(is_active, false) + FROM users WHERE id = $1`, userID).Scan(&row.ID, &row.Role, &row.StudentClass, &row.IsActive) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +type JoinExamRow struct { + ID int + Title string + Description *string + DurationMinutes int + StartTime time.Time + EndTime time.Time + Published bool + MaxAttempts int + AllowedClasses *string + AllowedStudents *string + CreatorRole *string +} + +func (e *JoinExamRow) AccessRow() *ExamRow { + if e == nil { + return nil + } + return &ExamRow{ + ID: e.ID, + AllowedClasses: e.AllowedClasses, + AllowedStudents: e.AllowedStudents, + CreatorRole: e.CreatorRole, + } +} + +func (s *Store) LookupJoinExamByToken(ctx context.Context, token string) (*JoinExamRow, error) { + if !s.HasPool() { + return nil, fmt.Errorf("no pgx pool") + } + var row JoinExamRow + err := s.pool.QueryRow(ctx, ` +SELECT e.id, e.title, e.description, e.duration_minutes, e.start_time, e.end_time, + COALESCE(e.is_published, false), COALESCE(e.max_attempts, 1), + e.allowed_classes, e.allowed_students, u.role + FROM exams e + JOIN users u ON u.id = e.creator_id + WHERE e.access_token = $1`, token).Scan( + &row.ID, &row.Title, &row.Description, &row.DurationMinutes, &row.StartTime, &row.EndTime, + &row.Published, &row.MaxAttempts, &row.AllowedClasses, &row.AllowedStudents, &row.CreatorRole, + ) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +func (s *Store) CountJoinQuestions(ctx context.Context, examID int) (int, error) { + if !s.HasPool() { + return 0, fmt.Errorf("no pgx pool") + } + var n int + err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM questions WHERE exam_id = $1`, examID).Scan(&n) + return n, err +} diff --git a/go/internal/persistence/persistence.go b/go/internal/persistence/persistence.go index bd560f6..c064511 100644 --- a/go/internal/persistence/persistence.go +++ b/go/internal/persistence/persistence.go @@ -9,9 +9,13 @@ import ( "strings" "time" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" ) +const pgPoolMaxConns int32 = 4 + const closeExpiredSQL = ` UPDATE exam_sessions es SET status = 'submitted', @@ -29,6 +33,7 @@ type Store struct { redisAddr string pgDSN string pool *pgxpool.Pool + redis *redis.Client } func Connect(databaseURL, redisURL string) *Store { @@ -38,21 +43,45 @@ func Connect(databaseURL, redisURL string) *Store { s.pgAddr = hostPortFromURL(s.pgDSN, 5432) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - pool, err := pgxpool.New(ctx, s.pgDSN) + poolConfig, err := pgbouncerPoolConfig(s.pgDSN) if err == nil { - if err := pool.Ping(ctx); err == nil { - s.pool = pool - } else { - pool.Close() + pool, err := pgxpool.NewWithConfig(ctx, poolConfig) + if err == nil { + if err := pool.Ping(ctx); err == nil { + s.pool = pool + } else { + pool.Close() + } } } } if redisURL != "" { s.redisAddr = hostPortFromURL(redisURL, 6379) + if options, err := redis.ParseURL(redisURL); err == nil { + client := redis.NewClient(options) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + if err := client.Ping(ctx).Err(); err == nil { + s.redis = client + } else { + _ = client.Close() + } + cancel() + } } return s } +func pgbouncerPoolConfig(databaseURL string) (*pgxpool.Config, error) { + config, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + return nil, err + } + config.MaxConns = pgPoolMaxConns + config.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol + config.ConnConfig.StatementCacheCapacity = 0 + return config, nil +} + func StripAsyncPG(raw string) string { return strings.ReplaceAll(raw, "+asyncpg", "") } @@ -120,6 +149,10 @@ func (s *Store) Close() { s.pool.Close() s.pool = nil } + if s != nil && s.redis != nil { + _ = s.redis.Close() + s.redis = nil + } } func hostPortFromURL(raw string, defaultPort int) string { diff --git a/go/internal/persistence/persistence_test.go b/go/internal/persistence/persistence_test.go new file mode 100644 index 0000000..d81b1c0 --- /dev/null +++ b/go/internal/persistence/persistence_test.go @@ -0,0 +1,26 @@ +package persistence + +import ( + "testing" + + "github.com/jackc/pgx/v5" +) + +func TestPgBouncerPoolConfigPinsScoredSettings(t *testing.T) { + config, err := pgbouncerPoolConfig( + "postgresql://examuser@example.test/siab1" + + "?pool_max_conns=9&default_query_exec_mode=cache_statement&statement_cache_capacity=512", + ) + if err != nil { + t.Fatal(err) + } + if config.MaxConns != 4 { + t.Fatalf("MaxConns=%d", config.MaxConns) + } + if config.ConnConfig.DefaultQueryExecMode != pgx.QueryExecModeSimpleProtocol { + t.Fatalf("DefaultQueryExecMode=%v", config.ConnConfig.DefaultQueryExecMode) + } + if config.ConnConfig.StatementCacheCapacity != 0 { + t.Fatalf("StatementCacheCapacity=%d", config.ConnConfig.StatementCacheCapacity) + } +} diff --git a/go/internal/persistence/start_native.go b/go/internal/persistence/start_native.go new file mode 100644 index 0000000..3e6223d --- /dev/null +++ b/go/internal/persistence/start_native.go @@ -0,0 +1,522 @@ +package persistence + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/redis/go-redis/v9" +) + +type StartSecuritySettings struct { + DeveloperMode bool + AllowMobileApps bool + MinimumAPKToken string + AllowedSignatures string +} + +type StartExamRow struct { + ID int + CreatorID int + Published bool + StartTime time.Time + EndTime time.Time + MaxAttempts int + AllowedClasses *string + AllowedStudents *string + DurationMinutes int + ShuffleQuestions bool + ShuffleOptions bool + Title string + Subject *string + ExamType *string + ShowResults bool + ShowTeacherName *bool + TeacherName *string + CreatorRole *string + SEBConfigKey string + SEBBrowserKey *string +} + +func (e *StartExamRow) TeacherVisible() bool { + return e != nil && e.ShowTeacherName != nil && *e.ShowTeacherName +} + +func (e *StartExamRow) ShowTeacher() bool { + return e == nil || e.ShowTeacherName == nil || *e.ShowTeacherName +} + +type StartSessionRow struct { + ID int + UserID int + ExamID int + Status string + StartTime time.Time + EndTime *time.Time + TerminatedByAdmin bool + EmergencyExitAllowed bool + ViolationCount int + TotalPausedSeconds int +} + +type StartSessionState struct { + AttemptCount int + Sessions []StartSessionRow +} + +type StartClientInfo struct { + IPAddress string + UserAgent string + SEBDetected bool + StartTime time.Time +} + +type SessionStartLog struct { + IP string + SEBDetected bool + Title string + Subject *string + ExamType *string + AllowedClasses *string + AllowedStudents *string + ExamStartTime time.Time + ExamEndTime time.Time + DurationMinutes int +} + +type StartTransaction interface { + Exam(context.Context, int) (*StartExamRow, error) + ValidateOptionIntegrity(context.Context, int) ([]int, error) + SessionState(context.Context, int, int) (StartSessionState, error) + AnswerCounts(context.Context, []int) (map[int]int, error) + SessionLogs(context.Context, int, int) ([]SessionLog, error) + RecoverSession(context.Context, StartSessionRow, string, string) (*StartSessionRow, error) + CreateSessionWithLog(context.Context, int, int, StartClientInfo, SessionStartLog) (*StartSessionRow, error) + Commit(context.Context) error + Rollback(context.Context) error +} + +type pgStartTransaction struct { + tx pgx.Tx +} + +func (s *Store) BeginStart(ctx context.Context) (StartTransaction, error) { + if s == nil || s.pool == nil { + return nil, fmt.Errorf("no pgx pool") + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + return &pgStartTransaction{tx: tx}, nil +} + +func (s *Store) LoadStartSecuritySettings(ctx context.Context) (StartSecuritySettings, error) { + if s == nil || s.pool == nil { + return StartSecuritySettings{}, fmt.Errorf("no pgx pool") + } + var row StartSecuritySettings + err := s.pool.QueryRow(ctx, ` +SELECT COALESCE(allow_browser_testing, false), + COALESCE(allow_mobile_apps, true), + COALESCE(minimum_apk_token, ''), + COALESCE(allowed_signatures, '') + FROM system_settings + ORDER BY id + LIMIT 1`).Scan( + &row.DeveloperMode, + &row.AllowMobileApps, + &row.MinimumAPKToken, + &row.AllowedSignatures, + ) + if errors.Is(err, pgx.ErrNoRows) { + return StartSecuritySettings{AllowMobileApps: true}, nil + } + return row, err +} + +func (s *Store) StartSEBKeys(ctx context.Context, examID int) (configKey, browserKey string, found bool, err error) { + if s == nil || s.pool == nil { + return "", "", false, fmt.Errorf("no pgx pool") + } + err = s.pool.QueryRow(ctx, ` +SELECT COALESCE(seb_config_key, ''), COALESCE(seb_browser_exam_key, '') + FROM exams + WHERE id = $1`, examID).Scan(&configKey, &browserKey) + if errors.Is(err, pgx.ErrNoRows) { + return "", "", false, nil + } + if err != nil { + return "", "", false, err + } + return configKey, browserKey, true, nil +} + +func (t *pgStartTransaction) Exam(ctx context.Context, examID int) (*StartExamRow, error) { + var row StartExamRow + err := t.tx.QueryRow(ctx, ` +SELECT e.id, e.creator_id, COALESCE(e.is_published, false), e.start_time, e.end_time, + COALESCE(e.max_attempts, 1), e.allowed_classes, e.allowed_students, + COALESCE(e.duration_minutes, 0), COALESCE(e.shuffle_questions, false), + COALESCE(e.shuffle_options, false), e.title, e.subject, e.exam_type, + COALESCE(e.show_results, false), e.show_teacher_name, + u.full_name, u.role, COALESCE(e.seb_config_key, ''), e.seb_browser_exam_key + FROM exams e + LEFT JOIN users u ON u.id = e.creator_id + WHERE e.id = $1`, examID).Scan( + &row.ID, &row.CreatorID, &row.Published, &row.StartTime, &row.EndTime, + &row.MaxAttempts, &row.AllowedClasses, &row.AllowedStudents, + &row.DurationMinutes, &row.ShuffleQuestions, &row.ShuffleOptions, + &row.Title, &row.Subject, &row.ExamType, &row.ShowResults, + &row.ShowTeacherName, &row.TeacherName, &row.CreatorRole, + &row.SEBConfigKey, &row.SEBBrowserKey, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +func (t *pgStartTransaction) ValidateOptionIntegrity(ctx context.Context, examID int) ([]int, error) { + rows, err := t.tx.Query(ctx, ` +SELECT q.id + FROM questions q + LEFT JOIN question_options qo ON q.id = qo.question_id + WHERE q.exam_id = $1 + AND qo.id IS NULL + AND ( + q.question_type IN ('multiple_choice', 'true_false') + OR ( + q.question_type = 'multiple_choice_complex' + AND COALESCE(q.pgk_type, 'checkbox') <> 'table_validation' + ) + ) + GROUP BY q.id, q.question_text, q.question_type`, examID) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []int + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func (t *pgStartTransaction) SessionState(ctx context.Context, userID, examID int) (StartSessionState, error) { + rows, err := t.tx.Query(ctx, ` +WITH attempt_count AS ( + SELECT COUNT(*)::int AS count + FROM exam_sessions + WHERE user_id = $1 AND exam_id = $2 + AND status IN ('completed', 'submitted') +), existing AS ( + SELECT id, user_id, exam_id, status, start_time, end_time, + COALESCE(terminated_by_admin, false) AS terminated_by_admin, + COALESCE(emergency_exit_allowed, false) AS emergency_exit_allowed, + COALESCE(violation_count, 0) AS violation_count, + COALESCE(total_paused_seconds, 0) AS total_paused_seconds + FROM exam_sessions + WHERE user_id = $1 AND exam_id = $2 + AND status IN ('in_progress', 'active', 'terminated', 'kicked') + ORDER BY start_time DESC, id DESC + LIMIT 16 +) +SELECT attempt_count.count, existing.id, existing.user_id, existing.exam_id, + existing.status, existing.start_time, existing.end_time, + existing.terminated_by_admin, existing.emergency_exit_allowed, + existing.violation_count, existing.total_paused_seconds + FROM attempt_count + LEFT JOIN existing ON true`, userID, examID) + if err != nil { + return StartSessionState{}, err + } + defer rows.Close() + state := StartSessionState{} + for rows.Next() { + var id, uid, eid *int + var status *string + var start *time.Time + var end *time.Time + var terminated, emergency *bool + var violations, paused *int + if err := rows.Scan( + &state.AttemptCount, &id, &uid, &eid, &status, &start, &end, + &terminated, &emergency, &violations, &paused, + ); err != nil { + return StartSessionState{}, err + } + if id == nil { + continue + } + state.Sessions = append(state.Sessions, StartSessionRow{ + ID: *id, UserID: *uid, ExamID: *eid, Status: *status, + StartTime: *start, EndTime: end, + TerminatedByAdmin: *terminated, EmergencyExitAllowed: *emergency, + ViolationCount: *violations, TotalPausedSeconds: *paused, + }) + } + return state, rows.Err() +} + +func (t *pgStartTransaction) AnswerCounts(ctx context.Context, sessionIDs []int) (map[int]int, error) { + counts := map[int]int{} + if len(sessionIDs) == 0 { + return counts, nil + } + rows, err := t.tx.Query(ctx, ` +SELECT session_id, COUNT(*)::int + FROM answers + WHERE session_id = ANY($1) + GROUP BY session_id`, sessionIDs) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var id, count int + if err := rows.Scan(&id, &count); err != nil { + return nil, err + } + counts[id] = count + } + return counts, rows.Err() +} + +func (t *pgStartTransaction) SessionLogs(ctx context.Context, sessionID, limit int) ([]SessionLog, error) { + rows, err := t.tx.Query(ctx, ` +SELECT event_type, COALESCE(event_data, '{}'::jsonb), created_at + FROM exam_logs + WHERE session_id = $1 + ORDER BY created_at DESC, id DESC + LIMIT $2`, sessionID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var logs []SessionLog + for rows.Next() { + var log SessionLog + if err := rows.Scan(&log.EventType, &log.Data, &log.CreatedAt); err != nil { + return nil, err + } + logs = append(logs, log) + } + return logs, rows.Err() +} + +func (t *pgStartTransaction) RecoverSession( + ctx context.Context, + session StartSessionRow, + category string, + message string, +) (*StartSessionRow, error) { + err := t.tx.QueryRow(ctx, ` +UPDATE exam_sessions + SET status = 'in_progress', end_time = NULL, + terminated_by_admin = false, emergency_exit_allowed = false + WHERE id = $1 +RETURNING id, user_id, exam_id, status, start_time, end_time, + COALESCE(terminated_by_admin, false), + COALESCE(emergency_exit_allowed, false), + COALESCE(violation_count, 0), + COALESCE(total_paused_seconds, 0)`, session.ID).Scan( + &session.ID, &session.UserID, &session.ExamID, &session.Status, + &session.StartTime, &session.EndTime, &session.TerminatedByAdmin, + &session.EmergencyExitAllowed, &session.ViolationCount, + &session.TotalPausedSeconds, + ) + if err != nil { + return nil, err + } + payload, err := json.Marshal(map[string]any{ + "category": category, + "message": message, + "trigger": "start_exam_session", + }) + if err != nil { + return nil, err + } + if _, err := t.tx.Exec(ctx, ` +INSERT INTO exam_logs (session_id, event_type, event_data, created_at) +VALUES ($1, 'SESSION_AUTO_RESET_NETWORK', $2::jsonb, NOW())`, session.ID, string(payload)); err != nil { + return nil, err + } + return &session, nil +} + +func (t *pgStartTransaction) CreateSessionWithLog( + ctx context.Context, + userID int, + examID int, + client StartClientInfo, + logData SessionStartLog, +) (*StartSessionRow, error) { + var row StartSessionRow + err := t.tx.QueryRow(ctx, ` +INSERT INTO exam_sessions ( + user_id, exam_id, start_time, status, ip_address, user_agent, seb_detected +) VALUES ($1, $2, $6, 'in_progress', NULLIF($3, '')::inet, $4, $5) +RETURNING id, user_id, exam_id, status, start_time, end_time, + COALESCE(terminated_by_admin, false), + COALESCE(emergency_exit_allowed, false), + COALESCE(violation_count, 0), + COALESCE(total_paused_seconds, 0)`, + userID, examID, client.IPAddress, client.UserAgent, client.SEBDetected, client.StartTime, + ).Scan( + &row.ID, &row.UserID, &row.ExamID, &row.Status, &row.StartTime, + &row.EndTime, &row.TerminatedByAdmin, &row.EmergencyExitAllowed, + &row.ViolationCount, &row.TotalPausedSeconds, + ) + if err != nil { + return nil, err + } + payload, err := json.Marshal(map[string]any{ + "ip": logData.IP, + "seb_detected": logData.SEBDetected, + "exam_snapshot": map[string]any{ + "title": logData.Title, + "subject": logData.Subject, + "exam_type": logData.ExamType, + "allowed_classes": logData.AllowedClasses, + "allowed_students": logData.AllowedStudents, + "start_time": formatPythonTime(logData.ExamStartTime), + "end_time": formatPythonTime(logData.ExamEndTime), + "duration_minutes": logData.DurationMinutes, + }, + }) + if err != nil { + return nil, err + } + if _, err := t.tx.Exec(ctx, ` +INSERT INTO exam_logs (session_id, event_type, event_data, created_at) +VALUES ($1, 'SESSION_START', $2::jsonb, NOW())`, row.ID, string(payload)); err != nil { + return nil, err + } + return &row, nil +} + +func (t *pgStartTransaction) Commit(ctx context.Context) error { + return t.tx.Commit(ctx) +} + +func (t *pgStartTransaction) Rollback(ctx context.Context) error { + return t.tx.Rollback(ctx) +} + +func (s *Store) CanonicalActiveStartSession(ctx context.Context, userID, examID int) (*StartSessionRow, error) { + if s == nil || s.pool == nil { + return nil, fmt.Errorf("no pgx pool") + } + var row StartSessionRow + err := s.pool.QueryRow(ctx, ` +SELECT id, user_id, exam_id, status, start_time, end_time, + COALESCE(terminated_by_admin, false), + COALESCE(emergency_exit_allowed, false), + COALESCE(violation_count, 0), + COALESCE(total_paused_seconds, 0) + FROM exam_sessions + WHERE user_id = $1 AND exam_id = $2 + AND status IN ('in_progress', 'active') + ORDER BY start_time DESC, id DESC`, userID, examID).Scan( + &row.ID, &row.UserID, &row.ExamID, &row.Status, &row.StartTime, + &row.EndTime, &row.TerminatedByAdmin, &row.EmergencyExitAllowed, + &row.ViolationCount, &row.TotalPausedSeconds, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +func IsIntegrityError(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && len(pgErr.Code) >= 2 && pgErr.Code[:2] == "23" +} + +func (s *Store) RedisGet(ctx context.Context, key string) (string, bool, error) { + if s == nil || s.redis == nil { + return "", false, fmt.Errorf("no redis client") + } + value, err := s.redis.Get(ctx, key).Result() + if errors.Is(err, redis.Nil) { + return "", false, nil + } + return value, err == nil, err +} + +func (s *Store) RedisSet(ctx context.Context, key, value string, ttl time.Duration) error { + if s == nil || s.redis == nil { + return fmt.Errorf("no redis client") + } + return s.redis.Set(ctx, key, value, ttl).Err() +} + +func (s *Store) RedisSetNX(ctx context.Context, key, value string, ttl time.Duration) (bool, error) { + if s == nil || s.redis == nil { + return false, fmt.Errorf("no redis client") + } + return s.redis.SetNX(ctx, key, value, ttl).Result() +} + +func (s *Store) RedisDelete(ctx context.Context, key string) error { + if s == nil || s.redis == nil { + return fmt.Errorf("no redis client") + } + return s.redis.Del(ctx, key).Err() +} + +func (s *Store) RedisPublish(ctx context.Context, channel, value string) error { + if s == nil || s.redis == nil { + return fmt.Errorf("no redis client") + } + return s.redis.Publish(ctx, channel, value).Err() +} + +func (s *Store) RedisXAdd(ctx context.Context, key, event string, maxLen, ttlSeconds int) error { + if s == nil || s.redis == nil { + return fmt.Errorf("no redis client") + } + if maxLen < 500 { + maxLen = 500 + } + if ttlSeconds < 300 { + ttlSeconds = 300 + } + if err := s.redis.XAdd(ctx, &redis.XAddArgs{ + Stream: key, + MaxLen: int64(maxLen), + Approx: true, + Values: map[string]any{"event": event}, + }).Err(); err != nil { + return err + } + return s.redis.Expire(ctx, key, time.Duration(ttlSeconds)*time.Second).Err() +} + +func formatPythonTime(value time.Time) string { + return pythonISOTime(value) +} + +func pythonISOTime(value time.Time) string { + value = value.UTC().Truncate(time.Microsecond) + base := value.Format("2006-01-02T15:04:05") + if micros := value.Nanosecond() / 1000; micros > 0 { + base += fmt.Sprintf(".%06d", micros) + } + return base + "+00:00" +} diff --git a/go/internal/persistence/submit_native.go b/go/internal/persistence/submit_native.go new file mode 100644 index 0000000..613cf83 --- /dev/null +++ b/go/internal/persistence/submit_native.go @@ -0,0 +1,310 @@ +package persistence + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +type SubmitSessionRow struct { + ID int + ExamID int + Status string + Score *float64 + ViolationCount int + EndTime *time.Time + ShowResults bool + PassingScore *float64 +} + +type SubmitAnswerGrade struct { + ID int + QuestionID int + SelectedOptionID *int + SelectedOptionIDs []int32 + AnswerText *string + Metadata []byte + IsCorrect *bool + Points *float64 + AnsweredAt *time.Time +} + +type SubmitAnswerScore struct { + QuestionID int + IsCorrect *bool + Points *float64 +} + +type SubmitGradeOutput struct { + Percentage float64 + TotalPoints float64 + PointsEarned float64 + Breakdown []map[string]any + Scores []SubmitAnswerScore +} + +type SubmitGradeFunc func([]QuestionRow, []SubmitAnswerGrade) SubmitGradeOutput + +type SubmitFinalizeResult struct { + Status string + Row SubmitSessionRow + TotalPoints float64 + PointsEarned float64 + Percentage float64 + Breakdown []map[string]any +} + +func (s *Store) LoadSubmitSession(ctx context.Context, sessionID, userID int) (*SubmitSessionRow, error) { + if !s.HasPool() { + return nil, fmt.Errorf("no pgx pool") + } + var row SubmitSessionRow + err := s.pool.QueryRow(ctx, ` +SELECT es.id, es.exam_id, es.status, es.score, COALESCE(es.violation_count, 0), es.end_time, + COALESCE(e.show_results, true), e.passing_score + FROM exam_sessions es + JOIN exams e ON e.id = es.exam_id + WHERE es.id = $1 AND es.user_id = $2`, sessionID, userID).Scan( + &row.ID, &row.ExamID, &row.Status, &row.Score, &row.ViolationCount, &row.EndTime, + &row.ShowResults, &row.PassingScore, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +func (s *Store) FinalizeNativeSubmit( + ctx context.Context, + sessionID, userID int, + forceSubmit bool, + submittedAt time.Time, + grade SubmitGradeFunc, +) (SubmitFinalizeResult, error) { + result := SubmitFinalizeResult{} + if !s.HasPool() { + return result, fmt.Errorf("no pgx pool") + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return result, err + } + defer func() { _ = tx.Rollback(ctx) }() + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1, $2)`, sessionWriteLockNS, sessionID); err != nil { + return result, err + } + var row SubmitSessionRow + err = tx.QueryRow(ctx, ` +SELECT es.id, es.exam_id, es.status, es.score, COALESCE(es.violation_count, 0), es.end_time, + COALESCE(e.show_results, true), e.passing_score + FROM exam_sessions es + JOIN exams e ON e.id = es.exam_id + WHERE es.id = $1 AND es.user_id = $2 + FOR UPDATE OF es`, sessionID, userID).Scan( + &row.ID, &row.ExamID, &row.Status, &row.Score, &row.ViolationCount, &row.EndTime, + &row.ShowResults, &row.PassingScore, + ) + if errors.Is(err, pgx.ErrNoRows) { + return SubmitFinalizeResult{Status: "not_found"}, nil + } + if err != nil { + return result, err + } + status := strings.ToLower(strings.TrimSpace(row.Status)) + if status == "submitted" || status == "completed" { + if err := tx.Commit(ctx); err != nil { + return result, err + } + return SubmitFinalizeResult{Status: "already", Row: row}, nil + } + if status != "in_progress" { + return SubmitFinalizeResult{Status: "ended", Row: row}, nil + } + questions, err := loadQuestionsTx(ctx, tx, row.ExamID) + if err != nil { + return result, err + } + answers, err := listSubmitAnswersTx(ctx, tx, sessionID) + if err != nil { + return result, err + } + graded := grade(questions, answers) + for _, score := range graded.Scores { + if _, err := tx.Exec(ctx, ` +UPDATE answers SET is_correct = $1, points_earned = $2 + WHERE session_id = $3 AND question_id = $4`, + score.IsCorrect, score.Points, sessionID, score.QuestionID, + ); err != nil { + return result, err + } + } + if _, err := tx.Exec(ctx, ` +UPDATE exam_sessions + SET status = 'submitted', end_time = $1, score = $2 + WHERE id = $3`, submittedAt, graded.Percentage, sessionID); err != nil { + return result, err + } + if _, err := tx.Exec(ctx, ` +UPDATE exams SET has_ever_had_results = true + WHERE id = $1 AND COALESCE(has_ever_had_results, false) = false`, row.ExamID); err != nil { + return result, err + } + recovery := "session_submitted" + if forceSubmit { + recovery = "cheating_detected" + } + submittedPayload, _ := json.Marshal(map[string]any{ + "force_submit": forceSubmit, + "recovery_category": recovery, + "score": graded.Percentage, + "violation_count": row.ViolationCount, + }) + breakdownPayload, _ := json.Marshal(map[string]any{"score_breakdown": graded.Breakdown}) + if _, err := tx.Exec(ctx, ` +INSERT INTO exam_logs (session_id, event_type, event_data, created_at) +VALUES ($1, 'EXAM_SUBMITTED', $2::jsonb, $3)`, sessionID, string(submittedPayload), submittedAt); err != nil { + return result, err + } + if _, err := tx.Exec(ctx, ` +INSERT INTO exam_logs (session_id, event_type, event_data, created_at) +VALUES ($1, 'SCORE_BREAKDOWN', $2::jsonb, $3)`, sessionID, string(breakdownPayload), submittedAt); err != nil { + return result, err + } + if err := tx.Commit(ctx); err != nil { + return result, err + } + row.Status = "submitted" + score := graded.Percentage + row.Score = &score + row.EndTime = &submittedAt + return SubmitFinalizeResult{ + Status: "submitted", + Row: row, + TotalPoints: graded.TotalPoints, + PointsEarned: graded.PointsEarned, + Percentage: graded.Percentage, + Breakdown: graded.Breakdown, + }, nil +} + +func loadQuestionsTx(ctx context.Context, tx pgx.Tx, examID int) ([]QuestionRow, error) { + qrows, err := tx.Query(ctx, ` +SELECT id, question_text, stimulus, question_type, pgk_type, + COALESCE(difficulty_level, 'medium'), COALESCE(question_settings, '{}'::jsonb), + COALESCE(points, 1)::text, order_index, image_url, video_url, audio_url + FROM questions WHERE exam_id = $1 ORDER BY order_index, id`, examID) + if err != nil { + return nil, err + } + defer qrows.Close() + var questions []QuestionRow + ids := make([]int, 0) + for qrows.Next() { + var q QuestionRow + if err := qrows.Scan( + &q.ID, &q.Text, &q.Stimulus, &q.Type, &q.PgkType, &q.Difficulty, + &q.Settings, &q.PointsText, &q.OrderIndex, &q.ImageURL, &q.VideoURL, &q.AudioURL, + ); err != nil { + return nil, err + } + q.Points, _ = strconv.ParseFloat(q.PointsText, 64) + questions = append(questions, q) + ids = append(ids, q.ID) + } + if err := qrows.Err(); err != nil { + return nil, err + } + if len(ids) == 0 { + return questions, nil + } + orows, err := tx.Query(ctx, ` +SELECT id, question_id, option_text, order_index, + COALESCE(option_group, 'standard'), pair_id, COALESCE(is_correct, false) + FROM question_options + WHERE question_id = ANY($1) + ORDER BY order_index, id`, ids) + if err != nil { + return nil, err + } + defer orows.Close() + byQ := map[int][]OptionRow{} + for orows.Next() { + var o OptionRow + if err := orows.Scan(&o.ID, &o.QuestionID, &o.Text, &o.OrderIndex, &o.OptionGroup, &o.PairID, &o.IsCorrect); err != nil { + return nil, err + } + byQ[o.QuestionID] = append(byQ[o.QuestionID], o) + } + if err := orows.Err(); err != nil { + return nil, err + } + for i := range questions { + questions[i].Options = byQ[questions[i].ID] + } + return questions, nil +} + +func listSubmitAnswersTx(ctx context.Context, tx pgx.Tx, sessionID int) ([]SubmitAnswerGrade, error) { + rows, err := tx.Query(ctx, ` +SELECT id, question_id, selected_option_id, selected_option_ids, answer_text, + COALESCE(answer_metadata, '{}'::jsonb), is_correct, points_earned, answered_at + FROM answers WHERE session_id = $1`, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []SubmitAnswerGrade + for rows.Next() { + var row SubmitAnswerGrade + if err := rows.Scan( + &row.ID, &row.QuestionID, &row.SelectedOptionID, &row.SelectedOptionIDs, + &row.AnswerText, &row.Metadata, &row.IsCorrect, &row.Points, &row.AnsweredAt, + ); err != nil { + return nil, err + } + out = append(out, row) + } + return out, rows.Err() +} + +func (s *Store) PatchSubmittedSnapshot(ctx context.Context, sessionID, userID, examID, violationCount int, endTime *time.Time) error { + if !s.HasRedis() { + return fmt.Errorf("no redis client") + } + key := fmt.Sprintf("exam_session:%d", sessionID) + raw, ok, err := s.RedisGet(ctx, key) + if err != nil || !ok { + return err + } + var snapshot map[string]any + if json.Unmarshal([]byte(raw), &snapshot) != nil { + return nil + } + if snapshotUser, ok := asInt(snapshot["user_id"]); ok && snapshotUser != userID { + return nil + } + snapshot["session_id"] = sessionID + snapshot["exam_id"] = examID + snapshot["status"] = "submitted" + if endTime != nil { + snapshot["end_time"] = endTime.UTC().Format("2006-01-02T15:04:05.000000+00:00") + } else { + snapshot["end_time"] = nil + } + snapshot["answered_count_stale"] = false + snapshot["violation_count"] = violationCount + encoded, err := json.Marshal(snapshot) + if err != nil { + return err + } + return s.RedisSet(ctx, key, string(encoded), 7200*time.Second) +} diff --git a/go/internal/security/headers.go b/go/internal/security/headers.go index be746b7..88c7898 100644 --- a/go/internal/security/headers.go +++ b/go/internal/security/headers.go @@ -18,9 +18,13 @@ const csp = "default-src 'self'; " + "form-action 'self'; " + "frame-ancestors 'none';" -func Headers(next http.Handler) http.Handler { +func Headers(next http.Handler, replica string) http.Handler { + replica = strings.TrimSpace(replica) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() + if replica != "" { + h.Set("X-SIAB-Replica", replica) + } h.Set("X-Frame-Options", "DENY") h.Set("X-Content-Type-Options", "nosniff") h.Set("X-XSS-Protection", "1; mode=block") diff --git a/go/internal/security/sxb.go b/go/internal/security/sxb.go index f8312a2..8d579c0 100644 --- a/go/internal/security/sxb.go +++ b/go/internal/security/sxb.go @@ -9,7 +9,6 @@ import ( var protectedPaths = []*regexp.Regexp{ regexp.MustCompile(`^/student/exam`), - regexp.MustCompile(`^/api/exams/\d+/start`), regexp.MustCompile(`^/api/exams/\d+/submit`), regexp.MustCompile(`^/api/exams/submit$`), regexp.MustCompile(`^/api/exams/submit-answer$`), @@ -20,6 +19,8 @@ var protectedPaths = []*regexp.Regexp{ regexp.MustCompile(`^/api/sessions/\d+`), } +var nativeStartPath = regexp.MustCompile(`^/api/exams/\d+/start$`) + var sxbWhitelist = []string{ "/static", "/admin", @@ -35,6 +36,11 @@ const sxbDenied = "Akses ditolak. Gunakan Aplikasi Ujian (APK) atau Safe Exam Br func SXB(enforce bool) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Native START performs the full FastAPI SXB + SEB chain in one handler. + if nativeStartPath.MatchString(r.URL.Path) { + next.ServeHTTP(w, r) + return + } if !enforce { next.ServeHTTP(w, r) return diff --git a/runtime_control/nginx.answer-canary.conf b/runtime_control/nginx.answer-canary.conf new file mode 100644 index 0000000..33863d9 --- /dev/null +++ b/runtime_control/nginx.answer-canary.conf @@ -0,0 +1,8 @@ +map $request_uri $go_answer_canary { + default fastapi; +} + +map $go_answer_canary $answer_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/runtime_control/nginx.autosave-canary.conf b/runtime_control/nginx.autosave-canary.conf new file mode 100644 index 0000000..94647d5 --- /dev/null +++ b/runtime_control/nginx.autosave-canary.conf @@ -0,0 +1,8 @@ +map $request_uri $go_autosave_canary { + default fastapi; +} + +map $go_autosave_canary $autosave_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/runtime_control/nginx.batch-canary.conf b/runtime_control/nginx.batch-canary.conf new file mode 100644 index 0000000..59dc94b --- /dev/null +++ b/runtime_control/nginx.batch-canary.conf @@ -0,0 +1,8 @@ +map $request_uri $go_batch_canary { + default fastapi; +} + +map $go_batch_canary $batch_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/runtime_control/nginx.join-canary.conf b/runtime_control/nginx.join-canary.conf new file mode 100644 index 0000000..32a6279 --- /dev/null +++ b/runtime_control/nginx.join-canary.conf @@ -0,0 +1,9 @@ +# Fail-safe default: every JOIN request stays on FastAPI. +map $request_uri $go_join_canary { + default fastapi; +} + +map $go_join_canary $join_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/runtime_control/nginx.start-canary.conf b/runtime_control/nginx.start-canary.conf new file mode 100644 index 0000000..95ee28b --- /dev/null +++ b/runtime_control/nginx.start-canary.conf @@ -0,0 +1,9 @@ +# Fail-safe default: every START request stays on FastAPI. +map $request_uri $go_start_canary { + default fastapi; +} + +map $go_start_canary $start_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/runtime_control/nginx.submit-canary.conf b/runtime_control/nginx.submit-canary.conf new file mode 100644 index 0000000..e614c8d --- /dev/null +++ b/runtime_control/nginx.submit-canary.conf @@ -0,0 +1,8 @@ +map $request_uri $go_submit_canary { + default fastapi; +} + +map $go_submit_canary $submit_backend { + default fastapi_backend; + go go_start_backend; +} diff --git a/scripts/go_answer_canary_control.sh b/scripts/go_answer_canary_control.sh new file mode 100644 index 0000000..8860df8 --- /dev/null +++ b/scripts/go_answer_canary_control.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MODE="${1:-status}" +COMPOSE_FILE="${COMPOSE_FILE:-$ROOT/docker-compose.production.yml}" +PROJECT="${COMPOSE_PROJECT:-siab1}" +RUNTIME="$ROOT/runtime_control/nginx.answer-canary.conf" +OFF="$ROOT/docker/nginx.answer-canary-off.conf" +MODES=(5pct 10pct 25pct 50pct 75pct 100pct) + +compose() { + docker compose -p "$PROJECT" -f "$COMPOSE_FILE" "$@" +} + +reload_nginx() { + compose exec -T nginx nginx -t + compose exec -T nginx nginx -s reload +} + +mode_file() { + local name="$1" + if [ "$name" = "off" ]; then + printf "%s\n" "$OFF" + return + fi + printf "%s\n" "$ROOT/docker/nginx.answer-canary-${name}.conf" +} + +current_mode() { + if cmp -s "$RUNTIME" "$OFF"; then + echo "off" + return + fi + local name + for name in "${MODES[@]}"; do + if cmp -s "$RUNTIME" "$(mode_file "$name")"; then + echo "$name" + return + fi + done + echo "unknown" +} + +apply_mode() { + local name="$1" + local src + src="$(mode_file "$name")" + if [ ! -f "$src" ]; then + echo "missing $src" >&2 + exit 2 + fi + cat "$src" > "$RUNTIME" + reload_nginx + printf "answer_canary_mode=%s\n" "$(current_mode)" +} + +case "$MODE" in + status) + printf "answer_canary_mode=%s\n" "$(current_mode)" + ;; + off|rollback) + apply_mode off + ;; + 5pct|10pct|25pct|50pct|75pct|100pct) + apply_mode "$MODE" + ;; + *) + echo "usage: $0 status|off|rollback|5pct|10pct|25pct|50pct|75pct|100pct" >&2 + exit 2 + ;; +esac diff --git a/scripts/go_answer_stage0.py b/scripts/go_answer_stage0.py new file mode 100644 index 0000000..304543b --- /dev/null +++ b/scripts/go_answer_stage0.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import json +import os +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone +from typing import Any +from urllib.parse import urlparse, urlunparse + +import asyncpg +import httpx +import jwt +import redis + + +PREFIX = os.getenv("GOANS_PREFIX", "GOANS0") +CLASS_NAME = "XII-GO-ANS" +PHASES = (10, 25, 50) +SEB_KEY = "ans-stage0-seb" + + +def postgres_dsn(raw: str) -> str: + parsed = urlparse(raw.replace("postgresql+asyncpg://", "postgresql://", 1)) + return urlunparse(parsed._replace(query="")) + + +def mint_token(user_id: int, username: str, secret: str) -> str: + now = datetime.now(timezone.utc) + return jwt.encode( + { + "sub": str(user_id), + "username": username, + "role": "student", + "full_name": username, + "student_class": CLASS_NAME, + "is_active": True, + "exp": int((now + timedelta(hours=2)).timestamp()), + }, + secret, + algorithm="HS256", + ) + + +def answer_one(base: str, token: str, session_id: int, question_id: int, option_id: int) -> dict[str, Any]: + started = datetime.now(timezone.utc) + response = httpx.post( + f"{base.rstrip('/')}/api/exams/submit-answer", + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "SEB/3.6 (Safe Exam Browser)", + "X-SafeExamBrowser-ConfigKeyHash": hashlib.sha256(SEB_KEY.encode()).hexdigest(), + }, + json={"session_id": session_id, "question_id": question_id, "selected_option_id": option_id}, + timeout=30.0, + ) + body: Any + try: + body = response.json() + except Exception: + body = response.text[:200] + return { + "status": response.status_code, + "body": body, + "elapsed_ms": (datetime.now(timezone.utc) - started).total_seconds() * 1000, + "replica": response.headers.get("x-siab-replica", ""), + } + + +async def cleanup(conn: asyncpg.Connection, rds: redis.Redis) -> None: + user_ids = [int(r["id"]) for r in await conn.fetch("SELECT id FROM users WHERE username LIKE $1", f"{PREFIX}_%")] + exam_ids = [int(r["id"]) for r in await conn.fetch("SELECT id FROM exams WHERE title LIKE $1", f"{PREFIX}_%")] + sids: list[int] = [] + if user_ids or exam_ids: + rows = await conn.fetch( + """ + SELECT id FROM exam_sessions + WHERE ($1::int[] = '{}'::int[] OR user_id = ANY($1::int[])) + OR ($2::int[] = '{}'::int[] OR exam_id = ANY($2::int[])) + """, + user_ids or [], + exam_ids or [], + ) + sids = [int(r["id"]) for r in rows] + if sids: + await conn.execute("DELETE FROM answers WHERE session_id = ANY($1::int[])", sids) + await conn.execute("DELETE FROM exam_logs WHERE session_id = ANY($1::int[])", sids) + await conn.execute("DELETE FROM exam_sessions WHERE id = ANY($1::int[])", sids) + for sid in sids: + rds.delete(f"exam_session:{sid}", f"exam_answers:{sid}", f"exam_answered_questions:{sid}") + for eid in exam_ids: + await conn.execute( + "DELETE FROM question_options WHERE question_id IN (SELECT id FROM questions WHERE exam_id=$1)", + eid, + ) + await conn.execute("DELETE FROM questions WHERE exam_id=$1", eid) + await conn.execute("DELETE FROM exams WHERE id=$1", eid) + if user_ids: + await conn.execute("DELETE FROM users WHERE id = ANY($1::int[])", user_ids) + + +async def seed(conn: asyncpg.Connection, count: int) -> dict[str, Any]: + now = datetime.now(timezone.utc) + teacher = int( + await conn.fetchval( + """ + INSERT INTO users (username, password_hash, full_name, role, is_active) + VALUES ($1, 'x', 'Go Ans0 Teacher', 'teacher', true) RETURNING id + """, + f"{PREFIX}_teacher", + ) + ) + exam_id = int( + await conn.fetchval( + """ + INSERT INTO exams ( + title, creator_id, duration_minutes, start_time, end_time, max_attempts, + shuffle_questions, shuffle_options, show_results, seb_config_key, + is_published, subject, exam_type, show_teacher_name, access_token, + is_deleted, has_ever_had_results + ) VALUES ( + $1, $2, 90, $3, $4, 3, false, false, false, $5, + true, 'MTK', 'UTS', true, 'ANS000', false, false + ) RETURNING id + """, + f"{PREFIX}_exam", + teacher, + now - timedelta(hours=1), + now + timedelta(hours=3), + SEB_KEY, + ) + ) + qid = int( + await conn.fetchval( + """ + INSERT INTO questions (exam_id, question_text, question_type, difficulty_level, points, order_index, question_settings) + VALUES ($1, 'MC', 'multiple_choice', 'easy', 1, 0, '{}'::jsonb) RETURNING id + """, + exam_id, + ) + ) + oid = int( + await conn.fetchval( + "INSERT INTO question_options (question_id, option_text, is_correct, order_index) VALUES ($1,'A',true,0) RETURNING id", + qid, + ) + ) + students: list[int] = [] + sessions: list[int] = [] + names: list[str] = [] + for i in range(count): + name = f"{PREFIX}_s{i:03d}" + uid = int( + await conn.fetchval( + """ + INSERT INTO users (username, password_hash, full_name, role, student_class, is_active) + VALUES ($1, 'x', $1, 'student', $2, true) RETURNING id + """, + name, + CLASS_NAME, + ) + ) + sid = int( + await conn.fetchval( + """ + INSERT INTO exam_sessions (user_id, exam_id, status, start_time) + VALUES ($1, $2, 'in_progress', $3) RETURNING id + """, + uid, + exam_id, + now, + ) + ) + students.append(uid) + sessions.append(sid) + names.append(name) + return { + "exam_id": exam_id, + "question_id": qid, + "option_id": oid, + "student_ids": students, + "session_ids": sessions, + "usernames": names, + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Stage-0 synthetic ANSWER against Go") + parser.add_argument("--go-url", default=os.getenv("GO_ANSWER_URL", "http://go_server:8000")) + parser.add_argument("--answer-url", default=os.getenv("GO_ANSWER_HTTP_URL", "")) + parser.add_argument("--database-url", default=os.getenv("DATABASE_URL", "")) + parser.add_argument("--redis-url", default=os.getenv("REDIS_URL", "redis://redis:6379/0")) + parser.add_argument("--jwt-secret", default=os.getenv("JWT_SECRET_KEY", "")) + parser.add_argument("--phases", default=",".join(str(item) for item in PHASES)) + parser.add_argument("--allow-mixed-replica", action="store_true") + return parser.parse_args() + + +async def amain() -> dict[str, Any]: + args = parse_args() + if not args.database_url or not args.jwt_secret: + raise SystemExit("DATABASE_URL and JWT_SECRET_KEY are required") + phases = [int(item) for item in args.phases.split(",") if item.strip()] + answer_url = (args.answer_url or args.go_url).rstrip("/") + rds = redis.Redis.from_url(args.redis_url, decode_responses=True) + conn = await asyncpg.connect(postgres_dsn(args.database_url), statement_cache_size=0) + report: dict[str, Any] = {"phases": [], "errors": [], "answer_url": answer_url} + try: + await cleanup(conn, rds) + fixture = await seed(conn, sum(phases)) + tokens = [ + mint_token(user_id, username, args.jwt_secret) + for user_id, username in zip(fixture["student_ids"], fixture["usernames"]) + ] + health = httpx.get(f"{args.go_url.rstrip('/')}/health", timeout=5.0) + if health.status_code != 200: + raise RuntimeError(f"go health {health.status_code}") + cursor = 0 + for count in phases: + slice_tokens = tokens[cursor : cursor + count] + slice_sessions = fixture["session_ids"][cursor : cursor + count] + cursor += count + with ThreadPoolExecutor(max_workers=count) as pool: + results = list( + pool.map( + lambda pair: answer_one( + answer_url, pair[0], pair[1], fixture["question_id"], fixture["option_id"] + ), + zip(slice_tokens, slice_sessions), + ) + ) + errors: list[str] = [] + success = sum(item["status"] == 200 for item in results) + if success != count: + errors.append(f"http_success={success}/{count}") + go_count = sum(item["replica"] == "go-start" for item in results) + if not args.allow_mixed_replica and go_count != count: + errors.append(f"replica={sorted({item['replica'] for item in results})}") + saved = int( + await conn.fetchval( + "SELECT count(*) FROM answers WHERE session_id = ANY($1::int[])", + slice_sessions, + ) + or 0 + ) + if saved != count: + errors.append(f"lost_answers saved={saved} expected={count}") + elapsed = sorted(item["elapsed_ms"] for item in results) + + def pct(p: float) -> float: + if not elapsed: + return 0.0 + idx = min(len(elapsed) - 1, max(0, int(round((p / 100) * (len(elapsed) - 1))))) + return round(elapsed[idx], 2) + + report["phases"].append( + { + "users": count, + "success": success, + "go_answer": go_count, + "saved": saved, + "p95_ms": pct(95), + "errors": errors, + } + ) + report["errors"].extend(errors) + await cleanup(conn, rds) + leftover = int(await conn.fetchval("SELECT count(*) FROM users WHERE username LIKE $1", f"{PREFIX}_%") or 0) + report["cleanup"] = "PASS" if leftover == 0 else "FAIL" + if leftover: + report["errors"].append(f"leftovers={leftover}") + finally: + await conn.close() + report["verdict"] = "PASS" if not report["errors"] else "FAIL" + print(json.dumps(report, default=str)) + return report + + +def main() -> int: + result = asyncio.run(amain()) + return 0 if result.get("verdict") == "PASS" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/go_autosave_canary_control.sh b/scripts/go_autosave_canary_control.sh new file mode 100755 index 0000000..60997af --- /dev/null +++ b/scripts/go_autosave_canary_control.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MODE="${1:-status}" +COMPOSE_FILE="${COMPOSE_FILE:-$ROOT/docker-compose.production.yml}" +PROJECT="${COMPOSE_PROJECT:-siab1}" +RUNTIME="$ROOT/runtime_control/nginx.autosave-canary.conf" +OFF="$ROOT/docker/nginx.autosave-canary-off.conf" +MODES=(5pct 10pct 25pct 50pct 75pct 100pct) + +compose() { + docker compose -p "$PROJECT" -f "$COMPOSE_FILE" "$@" +} + +reload_nginx() { + compose exec -T nginx nginx -t + compose exec -T nginx nginx -s reload +} + +mode_file() { + local name="$1" + if [ "$name" = "off" ]; then + printf "%s\n" "$OFF" + return + fi + printf "%s\n" "$ROOT/docker/nginx.autosave-canary-${name}.conf" +} + +current_mode() { + if cmp -s "$RUNTIME" "$OFF"; then + echo "off" + return + fi + local name + for name in "${MODES[@]}"; do + if cmp -s "$RUNTIME" "$(mode_file "$name")"; then + echo "$name" + return + fi + done + echo "unknown" +} + +apply_mode() { + local name="$1" + local src + src="$(mode_file "$name")" + if [ ! -f "$src" ]; then + echo "missing $src" >&2 + exit 2 + fi + cat "$src" > "$RUNTIME" + reload_nginx + printf "autosave_canary_mode=%s\n" "$(current_mode)" +} + +case "$MODE" in + status) + printf "autosave_canary_mode=%s\n" "$(current_mode)" + ;; + off|rollback) + apply_mode off + ;; + 5pct|10pct|25pct|50pct|75pct|100pct) + apply_mode "$MODE" + ;; + *) + echo "usage: $0 status|off|rollback|5pct|10pct|25pct|50pct|75pct|100pct" >&2 + exit 2 + ;; +esac diff --git a/scripts/go_batch_canary_control.sh b/scripts/go_batch_canary_control.sh new file mode 100755 index 0000000..f1174a4 --- /dev/null +++ b/scripts/go_batch_canary_control.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MODE="${1:-status}" +COMPOSE_FILE="${COMPOSE_FILE:-$ROOT/docker-compose.production.yml}" +PROJECT="${COMPOSE_PROJECT:-siab1}" +RUNTIME="$ROOT/runtime_control/nginx.batch-canary.conf" +OFF="$ROOT/docker/nginx.batch-canary-off.conf" +MODES=(5pct 10pct 25pct 50pct 75pct 100pct) + +compose() { + docker compose -p "$PROJECT" -f "$COMPOSE_FILE" "$@" +} + +reload_nginx() { + compose exec -T nginx nginx -t + compose exec -T nginx nginx -s reload +} + +mode_file() { + local name="$1" + if [ "$name" = "off" ]; then + printf "%s\n" "$OFF" + return + fi + printf "%s\n" "$ROOT/docker/nginx.batch-canary-${name}.conf" +} + +current_mode() { + if cmp -s "$RUNTIME" "$OFF"; then + echo "off" + return + fi + local name + for name in "${MODES[@]}"; do + if cmp -s "$RUNTIME" "$(mode_file "$name")"; then + echo "$name" + return + fi + done + echo "unknown" +} + +apply_mode() { + local name="$1" + local src + src="$(mode_file "$name")" + if [ ! -f "$src" ]; then + echo "missing $src" >&2 + exit 2 + fi + cat "$src" > "$RUNTIME" + reload_nginx + printf "batch_canary_mode=%s\n" "$(current_mode)" +} + +case "$MODE" in + status) + printf "batch_canary_mode=%s\n" "$(current_mode)" + ;; + off|rollback) + apply_mode off + ;; + 5pct|10pct|25pct|50pct|75pct|100pct) + apply_mode "$MODE" + ;; + *) + echo "usage: $0 status|off|rollback|5pct|10pct|25pct|50pct|75pct|100pct" >&2 + exit 2 + ;; +esac diff --git a/scripts/go_hotpath_lifecycle.py b/scripts/go_hotpath_lifecycle.py new file mode 100644 index 0000000..8aa015e --- /dev/null +++ b/scripts/go_hotpath_lifecycle.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone +from typing import Any +from urllib.parse import urlparse, urlunparse + +import asyncpg +import httpx +import jwt +import redis + + +PREFIX = os.getenv("GOLIFE_PREFIX", "GOLIFE") +CLASS_NAME = "XII-GO-LIFE" +SEB_KEY = "life-seb" +BASE = os.getenv("HOTPATH_BASE", "http://nginx").rstrip("/") + + +def postgres_dsn(raw: str) -> str: + parsed = urlparse(raw.replace("postgresql+asyncpg://", "postgresql://", 1)) + return urlunparse(parsed._replace(query="")) + + +def mint(user_id: int, username: str, secret: str) -> str: + now = datetime.now(timezone.utc) + return jwt.encode( + { + "sub": str(user_id), + "username": username, + "role": "student", + "full_name": username, + "student_class": CLASS_NAME, + "is_active": True, + "exp": int((now + timedelta(hours=2)).timestamp()), + }, + secret, + algorithm="HS256", + ) + + +def hdr(token: str) -> dict[str, str]: + return { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "SEB/3.6 (Safe Exam Browser)", + "X-SafeExamBrowser-ConfigKeyHash": hashlib.sha256(SEB_KEY.encode()).hexdigest(), + } + + +async def cleanup(conn: asyncpg.Connection, rds: redis.Redis) -> None: + user_ids = [int(r["id"]) for r in await conn.fetch("SELECT id FROM users WHERE username LIKE $1", f"{PREFIX}_%")] + exam_ids = [int(r["id"]) for r in await conn.fetch("SELECT id FROM exams WHERE title LIKE $1", f"{PREFIX}_%")] + sids: list[int] = [] + if user_ids or exam_ids: + rows = await conn.fetch( + """ + SELECT id FROM exam_sessions + WHERE ($1::int[] = '{}'::int[] OR user_id = ANY($1::int[])) + OR ($2::int[] = '{}'::int[] OR exam_id = ANY($2::int[])) + """, + user_ids or [], + exam_ids or [], + ) + sids = [int(r["id"]) for r in rows] + if sids: + await conn.execute("DELETE FROM answers WHERE session_id = ANY($1::int[])", sids) + await conn.execute("DELETE FROM exam_logs WHERE session_id = ANY($1::int[])", sids) + await conn.execute("DELETE FROM exam_sessions WHERE id = ANY($1::int[])", sids) + for sid in sids: + rds.delete(f"exam_session:{sid}", f"exam_answers:{sid}", f"exam_answered_questions:{sid}") + for eid in exam_ids: + await conn.execute( + "DELETE FROM question_options WHERE question_id IN (SELECT id FROM questions WHERE exam_id=$1)", eid + ) + await conn.execute("DELETE FROM questions WHERE exam_id=$1", eid) + await conn.execute("DELETE FROM exams WHERE id=$1", eid) + if user_ids: + await conn.execute("DELETE FROM users WHERE id = ANY($1::int[])", user_ids) + + +def one_lifecycle(token: str, exam_token: str, exam_id: int, qid: int, oid: int) -> dict[str, Any]: + errors: list[str] = [] + client = httpx.Client(timeout=30.0) + join = client.post(f"{BASE}/api/exams/join", headers=hdr(token), json={"token": exam_token}) + if join.status_code != 200: + return {"ok": False, "errors": [f"join {join.status_code}"], "elapsed_ms": 0} + started = datetime.now(timezone.utc) + start = client.post(f"{BASE}/api/exams/{exam_id}/start", headers=hdr(token), json={}) + if start.status_code != 200: + return {"ok": False, "errors": [f"start {start.status_code} {start.text[:120]}"], "elapsed_ms": 0} + session_id = int(start.json()["session_id"]) + ans = client.post( + f"{BASE}/api/exams/submit-answer", + headers=hdr(token), + json={"session_id": session_id, "question_id": qid, "selected_option_id": oid}, + ) + if ans.status_code != 200: + errors.append(f"answer {ans.status_code}") + auto = client.post( + f"{BASE}/api/exams/auto-save", + headers=hdr(token), + json={"session_id": session_id, "answers": {str(qid): oid}, "timestamp": datetime.now(timezone.utc).isoformat()}, + ) + if auto.status_code != 200: + errors.append(f"autosave {auto.status_code}") + resume = client.get(f"{BASE}/api/exams/session/{session_id}/resume", headers=hdr(token)) + if resume.status_code != 200: + errors.append(f"resume {resume.status_code}") + upd = client.post( + f"{BASE}/api/exams/submit-answer", + headers=hdr(token), + json={"session_id": session_id, "question_id": qid, "selected_option_id": oid}, + ) + if upd.status_code != 200: + errors.append(f"update {upd.status_code}") + sub = client.post(f"{BASE}/api/exams/submit", headers=hdr(token), json={"session_id": session_id}) + if sub.status_code != 200: + errors.append(f"submit {sub.status_code} {sub.text[:120]}") + elapsed = (datetime.now(timezone.utc) - started).total_seconds() * 1000 + return { + "ok": not errors, + "errors": errors, + "elapsed_ms": elapsed, + "session_id": session_id, + "join_replica": join.headers.get("x-siab-replica", ""), + "start_replica": start.headers.get("x-siab-replica", ""), + "answer_replica": ans.headers.get("x-siab-replica", ""), + } + + +async def amain() -> dict[str, Any]: + database_url = os.getenv("DATABASE_URL", "") + jwt_secret = os.getenv("JWT_SECRET_KEY", "") + redis_url = os.getenv("REDIS_URL", "redis://redis:6379/0") + mixed = int(os.getenv("HOTPATH_MIXED", "50")) + if not database_url or not jwt_secret: + raise SystemExit("DATABASE_URL and JWT_SECRET_KEY are required") + rds = redis.Redis.from_url(redis_url, decode_responses=True) + conn = await asyncpg.connect(postgres_dsn(database_url), statement_cache_size=0) + report: dict[str, Any] = {"errors": []} + try: + await cleanup(conn, rds) + now = datetime.now(timezone.utc) + teacher = int( + await conn.fetchval( + """ + INSERT INTO users (username, password_hash, full_name, role, is_active) + VALUES ($1,'x',$1,'teacher',true) RETURNING id + """, + f"{PREFIX}_teacher", + ) + ) + exam_id = int( + await conn.fetchval( + """ + INSERT INTO exams ( + title, creator_id, duration_minutes, start_time, end_time, max_attempts, + shuffle_questions, shuffle_options, show_results, seb_config_key, + is_published, subject, exam_type, show_teacher_name, access_token, + is_deleted, has_ever_had_results + ) VALUES ( + $1,$2,90,$3,$4,3,false,false,true,$5,true,'MTK','UTS',true,'LIFE01',false,false + ) RETURNING id + """, + f"{PREFIX}_exam", + teacher, + now - timedelta(hours=1), + now + timedelta(hours=3), + SEB_KEY, + ) + ) + qid = int( + await conn.fetchval( + """ + INSERT INTO questions (exam_id, question_text, question_type, difficulty_level, points, order_index, question_settings) + VALUES ($1,'MC','multiple_choice','easy',1,0,'{}'::jsonb) RETURNING id + """, + exam_id, + ) + ) + oid = int( + await conn.fetchval( + "INSERT INTO question_options (question_id, option_text, is_correct, order_index) VALUES ($1,'A',true,0) RETURNING id", + qid, + ) + ) + uid = int( + await conn.fetchval( + """ + INSERT INTO users (username, password_hash, full_name, role, student_class, is_active) + VALUES ($1,'x',$1,'student',$2,true) RETURNING id + """, + f"{PREFIX}_s", + CLASS_NAME, + ) + ) + tok = mint(uid, f"{PREFIX}_s", jwt_secret) + life = one_lifecycle(tok, "LIFE01", exam_id, qid, oid) + report["lifecycle"] = life + if not life["ok"]: + report["errors"].extend(life["errors"]) + session_id = int(life.get("session_id") or 0) + answers = int(await conn.fetchval("SELECT count(*) FROM answers WHERE session_id=$1", session_id) or 0) + status = await conn.fetchval("SELECT status FROM exam_sessions WHERE id=$1", session_id) + report["session_status"] = status + report["answers"] = answers + if status != "submitted" or answers < 1: + report["errors"].append(f"final state status={status} answers={answers}") + users = [] + tokens = [] + for i in range(mixed): + name = f"{PREFIX}_m{i:03d}" + mid = int( + await conn.fetchval( + """ + INSERT INTO users (username, password_hash, full_name, role, student_class, is_active) + VALUES ($1,'x',$1,'student',$2,true) RETURNING id + """, + name, + CLASS_NAME, + ) + ) + users.append(mid) + tokens.append(mint(mid, name, jwt_secret)) + with ThreadPoolExecutor(max_workers=mixed) as pool: + mixed_rows = list( + pool.map(lambda tok: one_lifecycle(tok, "LIFE01", exam_id, qid, oid), tokens) + ) + elapsed = sorted(item["elapsed_ms"] for item in mixed_rows if item["elapsed_ms"]) + success = sum(1 for item in mixed_rows if item["ok"]) + report["mixed"] = { + "n": mixed, + "success": success, + "correctness": round(100.0 * success / mixed, 2) if mixed else 0, + "p95": elapsed[min(len(elapsed) - 1, max(0, int(round(0.95 * (len(elapsed) - 1)))))] if elapsed else 0, + "p99": elapsed[min(len(elapsed) - 1, max(0, int(round(0.99 * (len(elapsed) - 1)))))] if elapsed else 0, + "errors": [item["errors"] for item in mixed_rows if not item["ok"]][:5], + } + if success != mixed: + report["errors"].append(f"mixed {success}/{mixed}") + origin = httpx.get("http://nginx/health", timeout=5).status_code + report["origin"] = origin + await cleanup(conn, rds) + leftover = int(await conn.fetchval("SELECT count(*) FROM users WHERE username LIKE $1", f"{PREFIX}_%") or 0) + report["cleanup"] = "PASS" if leftover == 0 else "FAIL" + if leftover: + report["errors"].append(f"leftovers={leftover}") + finally: + await conn.close() + report["verdict"] = "PASS" if not report["errors"] else "FAIL" + print(json.dumps(report, default=str)) + return report + + +if __name__ == "__main__": + raise SystemExit(0 if asyncio.run(amain()).get("verdict") == "PASS" else 1) diff --git a/scripts/go_join_canary_control.sh b/scripts/go_join_canary_control.sh new file mode 100755 index 0000000..ab89538 --- /dev/null +++ b/scripts/go_join_canary_control.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MODE="${1:-status}" +COMPOSE_FILE="${COMPOSE_FILE:-$ROOT/docker-compose.production.yml}" +PROJECT="${COMPOSE_PROJECT:-siab1}" +RUNTIME="$ROOT/runtime_control/nginx.join-canary.conf" +OFF="$ROOT/docker/nginx.join-canary-off.conf" +MODES=(5pct 10pct 25pct 50pct 75pct 100pct) + +compose() { + docker compose -p "$PROJECT" -f "$COMPOSE_FILE" "$@" +} + +reload_nginx() { + compose exec -T nginx nginx -t + compose exec -T nginx nginx -s reload +} + +mode_file() { + local name="$1" + if [ "$name" = "off" ]; then + printf "%s\n" "$OFF" + return + fi + printf "%s\n" "$ROOT/docker/nginx.join-canary-${name}.conf" +} + +current_mode() { + if cmp -s "$RUNTIME" "$OFF"; then + echo "off" + return + fi + local name + for name in "${MODES[@]}"; do + if cmp -s "$RUNTIME" "$(mode_file "$name")"; then + echo "$name" + return + fi + done + echo "unknown" +} + +apply_mode() { + local name="$1" + local src + src="$(mode_file "$name")" + if [ ! -f "$src" ]; then + echo "missing $src" >&2 + exit 2 + fi + cat "$src" > "$RUNTIME" + reload_nginx + printf "join_canary_mode=%s\n" "$(current_mode)" +} + +case "$MODE" in + status) + printf "join_canary_mode=%s\n" "$(current_mode)" + ;; + off|rollback) + apply_mode off + ;; + 5pct|10pct|25pct|50pct|75pct|100pct) + apply_mode "$MODE" + ;; + *) + echo "usage: $0 status|off|rollback|5pct|10pct|25pct|50pct|75pct|100pct" >&2 + exit 2 + ;; +esac diff --git a/scripts/go_join_stage0.py b/scripts/go_join_stage0.py new file mode 100755 index 0000000..933aa6b --- /dev/null +++ b/scripts/go_join_stage0.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import asyncio +import json +import os +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone +from typing import Any +from urllib.parse import urlparse, urlunparse + +import asyncpg +import httpx +import jwt +import redis + + +PREFIX = os.getenv("GOJOIN_PREFIX", "GOJOIN0") +CLASS_NAME = "XII-GO-JOIN" +PHASES = (10, 25, 50) +TOKEN = "JOIN00" + + +def postgres_dsn(raw: str) -> str: + parsed = urlparse(raw.replace("postgresql+asyncpg://", "postgresql://", 1)) + return urlunparse(parsed._replace(query="")) + + +def mint_token(user_id: int, username: str, secret: str) -> str: + now = datetime.now(timezone.utc) + return jwt.encode( + { + "sub": str(user_id), + "username": username, + "role": "student", + "full_name": username, + "student_class": CLASS_NAME, + "is_active": True, + "exp": int((now + timedelta(hours=2)).timestamp()), + }, + secret, + algorithm="HS256", + ) + + +def join_one(base: str, token: str) -> dict[str, Any]: + started = datetime.now(timezone.utc) + response = httpx.post( + f"{base.rstrip('/')}/api/exams/join", + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "Content-Type": "application/json", + }, + content=json.dumps({"token": TOKEN}).encode(), + timeout=30.0, + ) + elapsed_ms = (datetime.now(timezone.utc) - started).total_seconds() * 1000 + body: Any + try: + body = response.json() + except Exception: + body = response.text[:200] + return { + "status": response.status_code, + "body": body, + "elapsed_ms": elapsed_ms, + "replica": response.headers.get("x-siab-replica", ""), + } + + +async def connect_pg(dsn: str) -> asyncpg.Connection: + return await asyncpg.connect(postgres_dsn(dsn), statement_cache_size=0) + + +async def cleanup(conn: asyncpg.Connection, rds: redis.Redis) -> None: + user_ids = [ + int(row["id"]) + for row in await conn.fetch("SELECT id FROM users WHERE username LIKE $1", f"{PREFIX}_%") + ] + exam_ids = [ + int(row["id"]) + for row in await conn.fetch("SELECT id FROM exams WHERE title LIKE $1", f"{PREFIX}_%") + ] + session_ids: list[int] = [] + if user_ids or exam_ids: + rows = await conn.fetch( + """ + SELECT id FROM exam_sessions + WHERE ($1::int[] = '{}'::int[] OR user_id = ANY($1::int[])) + OR ($2::int[] = '{}'::int[] OR exam_id = ANY($2::int[])) + """, + user_ids or [], + exam_ids or [], + ) + session_ids = [int(row["id"]) for row in rows] + if session_ids: + await conn.execute("DELETE FROM answers WHERE session_id = ANY($1::int[])", session_ids) + await conn.execute("DELETE FROM exam_logs WHERE session_id = ANY($1::int[])", session_ids) + await conn.execute("DELETE FROM exam_sessions WHERE id = ANY($1::int[])", session_ids) + rds.delete(*[f"exam_session:{sid}" for sid in session_ids]) + for eid in exam_ids: + await conn.execute( + "DELETE FROM question_options WHERE question_id IN (SELECT id FROM questions WHERE exam_id=$1)", + eid, + ) + await conn.execute("DELETE FROM questions WHERE exam_id=$1", eid) + await conn.execute("DELETE FROM exams WHERE id=$1", eid) + if user_ids: + await conn.execute("DELETE FROM users WHERE id = ANY($1::int[])", user_ids) + + +async def seed(conn: asyncpg.Connection, count: int) -> dict[str, Any]: + now = datetime.now(timezone.utc) + teacher_id = int( + await conn.fetchval( + """ + INSERT INTO users (username, password_hash, full_name, role, is_active) + VALUES ($1, 'x', 'Go Join0 Teacher', 'teacher', true) + RETURNING id + """, + f"{PREFIX}_teacher", + ) + ) + student_ids: list[int] = [] + usernames: list[str] = [] + for index in range(count): + username = f"{PREFIX}_s{index:03d}" + student_ids.append( + int( + await conn.fetchval( + """ + INSERT INTO users (username, password_hash, full_name, role, student_class, is_active) + VALUES ($1, 'x', $1, 'student', $2, true) + RETURNING id + """, + username, + CLASS_NAME, + ) + ) + ) + usernames.append(username) + exam_id = int( + await conn.fetchval( + """ + INSERT INTO exams ( + title, creator_id, duration_minutes, start_time, end_time, max_attempts, + shuffle_questions, shuffle_options, show_results, seb_config_key, + is_published, subject, exam_type, show_teacher_name, allowed_classes, + allowed_students, access_token, is_deleted, has_ever_had_results + ) VALUES ( + $1, $2, 90, $3, $4, 3, false, false, false, 'join-seb', + true, 'MTK', 'UTS', true, $5, NULL, $6, false, false + ) + RETURNING id + """, + f"{PREFIX}_{TOKEN}", + teacher_id, + now - timedelta(hours=1), + now + timedelta(hours=3), + CLASS_NAME, + TOKEN, + ) + ) + for index in range(3): + await conn.execute( + """ + INSERT INTO questions (exam_id, question_text, question_type, difficulty_level, points, order_index, question_settings) + VALUES ($1, $2, 'multiple_choice', 'easy', 1, $3, '{}'::jsonb) + """, + exam_id, + f"Q{index+1}", + index, + ) + return {"exam_id": exam_id, "student_ids": student_ids, "usernames": usernames} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Stage-0 synthetic JOIN against Go") + parser.add_argument("--go-url", default=os.getenv("GO_JOIN_URL", "http://go_server:8000")) + parser.add_argument("--join-url", default=os.getenv("GO_JOIN_HTTP_URL", "")) + parser.add_argument("--database-url", default=os.getenv("DATABASE_URL", "")) + parser.add_argument("--redis-url", default=os.getenv("REDIS_URL", "redis://redis:6379/0")) + parser.add_argument("--jwt-secret", default=os.getenv("JWT_SECRET_KEY", "")) + parser.add_argument("--phases", default=",".join(str(item) for item in PHASES)) + parser.add_argument("--allow-mixed-replica", action="store_true") + return parser.parse_args() + + +async def amain() -> dict[str, Any]: + args = parse_args() + if not args.database_url or not args.jwt_secret: + raise SystemExit("DATABASE_URL and JWT_SECRET_KEY are required") + phases = [int(item) for item in args.phases.split(",") if item.strip()] + join_url = (args.join_url or args.go_url).rstrip("/") + rds = redis.Redis.from_url(args.redis_url, decode_responses=True) + conn = await connect_pg(args.database_url) + report: dict[str, Any] = {"phases": [], "errors": [], "join_url": join_url} + try: + await cleanup(conn, rds) + fixture = await seed(conn, sum(phases)) + tokens = [ + mint_token(user_id, username, args.jwt_secret) + for user_id, username in zip(fixture["student_ids"], fixture["usernames"]) + ] + health = httpx.get(f"{args.go_url.rstrip('/')}/health", timeout=5.0) + if health.status_code != 200: + raise RuntimeError(f"go health {health.status_code}") + cursor = 0 + for count in phases: + slice_tokens = tokens[cursor : cursor + count] + slice_ids = fixture["student_ids"][cursor : cursor + count] + cursor += count + with ThreadPoolExecutor(max_workers=count) as pool: + results = list(pool.map(lambda tok: join_one(join_url, tok), slice_tokens)) + errors: list[str] = [] + success = sum(item["status"] == 200 for item in results) + if success != count: + errors.append(f"http_success={success}/{count}") + go_count = sum(item["replica"] == "go-start" for item in results) + if not args.allow_mixed_replica and go_count != count: + errors.append(f"replica={sorted({item['replica'] for item in results})}") + sessions = int( + await conn.fetchval( + """ + SELECT count(*) FROM exam_sessions + WHERE exam_id=$1 AND user_id = ANY($2::int[]) + """, + fixture["exam_id"], + slice_ids, + ) + or 0 + ) + if sessions: + errors.append(f"sessions_created={sessions}") + elapsed = sorted(item["elapsed_ms"] for item in results) + + def pct(p: float) -> float: + if not elapsed: + return 0.0 + idx = min(len(elapsed) - 1, max(0, int(round((p / 100) * (len(elapsed) - 1))))) + return round(elapsed[idx], 2) + + report["phases"].append( + { + "users": count, + "success": success, + "go_join": go_count, + "p95_ms": pct(95), + "p99_ms": pct(99), + "errors": errors, + } + ) + report["errors"].extend(errors) + await cleanup(conn, rds) + leftover_users = int( + await conn.fetchval("SELECT count(*) FROM users WHERE username LIKE $1", f"{PREFIX}_%") or 0 + ) + leftover_exams = int( + await conn.fetchval("SELECT count(*) FROM exams WHERE title LIKE $1", f"{PREFIX}_%") or 0 + ) + report["cleanup"] = "PASS" if leftover_users == 0 and leftover_exams == 0 else "FAIL" + if report["cleanup"] != "PASS": + report["errors"].append(f"leftovers users={leftover_users} exams={leftover_exams}") + finally: + await conn.close() + report["verdict"] = "PASS" if not report["errors"] else "FAIL" + print(json.dumps(report, default=str)) + return report + + +def main() -> int: + result = asyncio.run(amain()) + return 0 if result.get("verdict") == "PASS" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/go_remaining_stage0.py b/scripts/go_remaining_stage0.py new file mode 100644 index 0000000..4d2024b --- /dev/null +++ b/scripts/go_remaining_stage0.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import json +import os +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone +from typing import Any +from urllib.parse import urlparse, urlunparse + +import asyncpg +import httpx +import jwt +import redis + + +PREFIX = os.getenv("GOREM_PREFIX", "GOREM0") +CLASS_NAME = "XII-GO-REM" +PHASES = (10, 25, 50) +SEB_KEY = "rem-stage0-seb" +SEB_HASH = hashlib.sha256(SEB_KEY.encode()).hexdigest() + + +def postgres_dsn(raw: str) -> str: + parsed = urlparse(raw.replace("postgresql+asyncpg://", "postgresql://", 1)) + return urlunparse(parsed._replace(query="")) + + +def mint_token(user_id: int, username: str, secret: str) -> str: + now = datetime.now(timezone.utc) + return jwt.encode( + { + "sub": str(user_id), + "username": username, + "role": "student", + "full_name": username, + "student_class": CLASS_NAME, + "is_active": True, + "exp": int((now + timedelta(hours=2)).timestamp()), + }, + secret, + algorithm="HS256", + ) + + +def headers(token: str, seb: bool) -> dict[str, str]: + out = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "SEB/3.6 (Safe Exam Browser)" if seb else "Mozilla/5.0", + } + if seb: + out["X-SafeExamBrowser-ConfigKeyHash"] = SEB_HASH + return out + + +def call_one(kind: str, base: str, token: str, session_id: int, question_id: int, option_id: int) -> dict[str, Any]: + started = datetime.now(timezone.utc) + if kind == "autosave": + path, body, seb = "/api/exams/auto-save", { + "session_id": session_id, + "answers": {str(question_id): option_id}, + "timestamp": datetime.now(timezone.utc).isoformat(), + }, False + elif kind == "batch": + path, body, seb = "/api/exams/auto-save-batch", { + "session_id": session_id, + "answers": [{"question_id": question_id, "selected_option_id": option_id}], + }, False + else: + path, body, seb = "/api/exams/submit", {"session_id": session_id}, True + response = httpx.post(f"{base.rstrip('/')}{path}", headers=headers(token, seb), json=body, timeout=30.0) + try: + payload = response.json() + except Exception: + payload = response.text[:200] + return { + "status": response.status_code, + "body": payload, + "elapsed_ms": (datetime.now(timezone.utc) - started).total_seconds() * 1000, + "replica": response.headers.get("x-siab-replica", ""), + } + + +async def cleanup(conn: asyncpg.Connection, rds: redis.Redis) -> None: + user_ids = [int(r["id"]) for r in await conn.fetch("SELECT id FROM users WHERE username LIKE $1", f"{PREFIX}_%")] + exam_ids = [int(r["id"]) for r in await conn.fetch("SELECT id FROM exams WHERE title LIKE $1", f"{PREFIX}_%")] + sids: list[int] = [] + if user_ids or exam_ids: + rows = await conn.fetch( + """ + SELECT id FROM exam_sessions + WHERE ($1::int[] = '{}'::int[] OR user_id = ANY($1::int[])) + OR ($2::int[] = '{}'::int[] OR exam_id = ANY($2::int[])) + """, + user_ids or [], + exam_ids or [], + ) + sids = [int(r["id"]) for r in rows] + if sids: + await conn.execute("DELETE FROM answers WHERE session_id = ANY($1::int[])", sids) + await conn.execute("DELETE FROM exam_logs WHERE session_id = ANY($1::int[])", sids) + await conn.execute("DELETE FROM exam_sessions WHERE id = ANY($1::int[])", sids) + for sid in sids: + rds.delete(f"exam_session:{sid}", f"exam_answers:{sid}", f"exam_answered_questions:{sid}") + for eid in exam_ids: + await conn.execute( + "DELETE FROM question_options WHERE question_id IN (SELECT id FROM questions WHERE exam_id=$1)", eid + ) + await conn.execute("DELETE FROM questions WHERE exam_id=$1", eid) + await conn.execute("DELETE FROM exams WHERE id=$1", eid) + if user_ids: + await conn.execute("DELETE FROM users WHERE id = ANY($1::int[])", user_ids) + + +async def seed(conn: asyncpg.Connection, count: int, kind: str) -> dict[str, Any]: + now = datetime.now(timezone.utc) + teacher = int( + await conn.fetchval( + "INSERT INTO users (username, password_hash, full_name, role, is_active) VALUES ($1,'x',$1,'teacher',true) RETURNING id", + f"{PREFIX}_teacher", + ) + ) + exam_id = int( + await conn.fetchval( + """ + INSERT INTO exams ( + title, creator_id, duration_minutes, start_time, end_time, max_attempts, + shuffle_questions, shuffle_options, show_results, passing_score, seb_config_key, + is_published, subject, exam_type, show_teacher_name, access_token, + is_deleted, has_ever_had_results + ) VALUES ( + $1,$2,90,$3,$4,3,false,false,true,70,$5,true,'MTK','UTS',true,'REM000',false,false + ) RETURNING id + """, + f"{PREFIX}_exam", + teacher, + now - timedelta(hours=1), + now + timedelta(hours=3), + SEB_KEY, + ) + ) + qid = int( + await conn.fetchval( + "INSERT INTO questions (exam_id, question_text, question_type, difficulty_level, points, order_index, question_settings) VALUES ($1,'MC','multiple_choice','easy',1,0,'{}'::jsonb) RETURNING id", + exam_id, + ) + ) + oid = int( + await conn.fetchval( + "INSERT INTO question_options (question_id, option_text, is_correct, order_index) VALUES ($1,'A',true,0) RETURNING id", + qid, + ) + ) + students, sessions, names = [], [], [] + for i in range(count): + name = f"{PREFIX}_s{i:03d}" + uid = int( + await conn.fetchval( + "INSERT INTO users (username, password_hash, full_name, role, student_class, is_active) VALUES ($1,'x',$1,'student',$2,true) RETURNING id", + name, + CLASS_NAME, + ) + ) + sid = int( + await conn.fetchval( + "INSERT INTO exam_sessions (user_id, exam_id, status, start_time) VALUES ($1,$2,'in_progress',$3) RETURNING id", + uid, + exam_id, + now, + ) + ) + if kind == "submit": + await conn.execute( + "INSERT INTO answers (session_id, question_id, selected_option_id, answered_at) VALUES ($1,$2,$3,$4)", + sid, + qid, + oid, + now, + ) + students.append(uid) + sessions.append(sid) + names.append(name) + return {"question_id": qid, "option_id": oid, "student_ids": students, "session_ids": sessions, "usernames": names} + + +async def amain() -> dict[str, Any]: + parser = argparse.ArgumentParser() + parser.add_argument("--kind", choices=("autosave", "batch", "submit"), required=True) + parser.add_argument("--go-url", default=os.getenv("GO_REMAIN_URL", "http://go_server:8000")) + parser.add_argument("--http-url", default=os.getenv("GO_REMAIN_HTTP_URL", "")) + parser.add_argument("--database-url", default=os.getenv("DATABASE_URL", "")) + parser.add_argument("--redis-url", default=os.getenv("REDIS_URL", "redis://redis:6379/0")) + parser.add_argument("--jwt-secret", default=os.getenv("JWT_SECRET_KEY", "")) + parser.add_argument("--phases", default=",".join(str(item) for item in PHASES)) + parser.add_argument("--allow-mixed-replica", action="store_true") + args = parser.parse_args() + if not args.database_url or not args.jwt_secret: + raise SystemExit("DATABASE_URL and JWT_SECRET_KEY are required") + phases = [int(item) for item in args.phases.split(",") if item.strip()] + http_url = (args.http_url or args.go_url).rstrip("/") + rds = redis.Redis.from_url(args.redis_url, decode_responses=True) + conn = await asyncpg.connect(postgres_dsn(args.database_url), statement_cache_size=0) + report: dict[str, Any] = {"kind": args.kind, "phases": [], "errors": [], "http_url": http_url} + try: + await cleanup(conn, rds) + fixture = await seed(conn, sum(phases), args.kind) + tokens = [mint_token(uid, name, args.jwt_secret) for uid, name in zip(fixture["student_ids"], fixture["usernames"])] + health = httpx.get(f"{args.go_url.rstrip('/')}/health", timeout=5.0) + if health.status_code != 200: + raise RuntimeError(f"go health {health.status_code}") + cursor = 0 + for count in phases: + slice_tokens = tokens[cursor : cursor + count] + slice_sessions = fixture["session_ids"][cursor : cursor + count] + cursor += count + with ThreadPoolExecutor(max_workers=count) as pool: + results = list( + pool.map( + lambda pair: call_one( + args.kind, http_url, pair[0], pair[1], fixture["question_id"], fixture["option_id"] + ), + zip(slice_tokens, slice_sessions), + ) + ) + errors: list[str] = [] + success = sum(item["status"] == 200 for item in results) + if success != count: + errors.append(f"http_success={success}/{count}") + go_count = sum(item["replica"] == "go-start" for item in results) + if not args.allow_mixed_replica and go_count != count: + errors.append(f"replica={sorted({item['replica'] for item in results})}") + if args.kind == "autosave": + cached = sum(1 for sid in slice_sessions if rds.get(f"exam_answers:{sid}")) + if cached != count: + errors.append(f"redis_saved={cached} expected={count}") + elif args.kind in {"batch", "submit"}: + saved = int(await conn.fetchval("SELECT count(*) FROM answers WHERE session_id = ANY($1::int[])", slice_sessions) or 0) + if saved != count: + errors.append(f"lost_answers saved={saved} expected={count}") + if args.kind == "submit": + submitted = int( + await conn.fetchval( + "SELECT count(*) FROM exam_sessions WHERE id = ANY($1::int[]) AND status='submitted'", + slice_sessions, + ) + or 0 + ) + if submitted != count: + errors.append(f"submitted={submitted} expected={count}") + elapsed = sorted(item["elapsed_ms"] for item in results) + + def pct(p: float) -> float: + if not elapsed: + return 0.0 + idx = min(len(elapsed) - 1, max(0, int(round((p / 100) * (len(elapsed) - 1))))) + return round(elapsed[idx], 2) + + report["phases"].append( + {"users": count, "success": success, "go": go_count, "p95_ms": pct(95), "errors": errors} + ) + report["errors"].extend(errors) + await cleanup(conn, rds) + leftover = int(await conn.fetchval("SELECT count(*) FROM users WHERE username LIKE $1", f"{PREFIX}_%") or 0) + report["cleanup"] = "PASS" if leftover == 0 else "FAIL" + if leftover: + report["errors"].append(f"leftovers={leftover}") + finally: + await conn.close() + report["verdict"] = "PASS" if not report["errors"] else "FAIL" + print(json.dumps(report, default=str)) + return report + + +if __name__ == "__main__": + raise SystemExit(0 if asyncio.run(amain()).get("verdict") == "PASS" else 1) diff --git a/scripts/go_start_canary_control.sh b/scripts/go_start_canary_control.sh new file mode 100755 index 0000000..1e2b149 --- /dev/null +++ b/scripts/go_start_canary_control.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MODE="${1:-status}" +COMPOSE_FILE="${COMPOSE_FILE:-$ROOT/docker-compose.production.yml}" +PROJECT="${COMPOSE_PROJECT:-siab1}" +RUNTIME="$ROOT/runtime_control/nginx.start-canary.conf" +OFF="$ROOT/docker/nginx.start-canary-off.conf" +MODES=(5pct 10pct 25pct 50pct 75pct 100pct) + +compose() { + docker compose -p "$PROJECT" -f "$COMPOSE_FILE" "$@" +} + +reload_nginx() { + compose exec -T nginx nginx -t + compose exec -T nginx nginx -s reload +} + +mode_file() { + local name="$1" + if [ "$name" = "off" ]; then + printf "%s\n" "$OFF" + return + fi + printf "%s\n" "$ROOT/docker/nginx.start-canary-${name}.conf" +} + +current_mode() { + if cmp -s "$RUNTIME" "$OFF"; then + echo "off" + return + fi + local name + for name in "${MODES[@]}"; do + if cmp -s "$RUNTIME" "$(mode_file "$name")"; then + echo "$name" + return + fi + done + echo "unknown" +} + +apply_mode() { + local name="$1" + local src + src="$(mode_file "$name")" + if [ ! -f "$src" ]; then + echo "missing $src" >&2 + exit 2 + fi + # Overwrite the existing inode so the nginx bind-mount sees the change. + cat "$src" > "$RUNTIME" + reload_nginx + printf "canary_mode=%s\n" "$(current_mode)" +} + +case "$MODE" in + status) + printf "canary_mode=%s\n" "$(current_mode)" + ;; + off|rollback) + apply_mode off + ;; + 5pct|10pct|25pct|50pct|75pct|100pct) + apply_mode "$MODE" + ;; + *) + echo "usage: $0 status|off|rollback|5pct|10pct|25pct|50pct|75pct|100pct" >&2 + exit 2 + ;; +esac diff --git a/scripts/go_start_stage0.py b/scripts/go_start_stage0.py new file mode 100755 index 0000000..885b35f --- /dev/null +++ b/scripts/go_start_stage0.py @@ -0,0 +1,465 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import json +import os +import time +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone +from typing import Any +from urllib.parse import urlparse, urlunparse + +import asyncpg +import httpx +import jwt +import redis + + +PREFIX = os.getenv("GOSTAGE_PREFIX", "GOSTAGE0") +CLASS_NAME = "XII-GO-START" +PHASES = (10, 25, 50) + + +def postgres_dsn(raw: str) -> str: + parsed = urlparse(raw.replace("postgresql+asyncpg://", "postgresql://", 1)) + return urlunparse(parsed._replace(query="")) + + +def mint_token(user_id: int, username: str, secret: str) -> str: + now = datetime.now(timezone.utc) + payload = { + "sub": str(user_id), + "username": username, + "role": "student", + "full_name": username, + "student_class": CLASS_NAME, + "is_active": True, + "exp": int((now + timedelta(hours=2)).timestamp()), + } + return jwt.encode(payload, secret, algorithm="HS256") + + +def seb_headers(token: str, seb_key: str) -> dict[str, str]: + return { + "Authorization": f"Bearer {token}", + "User-Agent": "SEB/3.6 (Safe Exam Browser)", + "X-SafeExamBrowser-ConfigKeyHash": hashlib.sha256(seb_key.encode()).hexdigest(), + "Accept": "application/json", + } + + +async def connect_pg(dsn: str) -> asyncpg.Connection: + return await asyncpg.connect(postgres_dsn(dsn), statement_cache_size=0) + + +async def cleanup(conn: asyncpg.Connection, rds: redis.Redis, exam_id: int | None) -> None: + user_ids = [ + int(row["id"]) + for row in await conn.fetch( + "SELECT id FROM users WHERE username LIKE $1", f"{PREFIX}_%" + ) + ] + exam_ids = [ + int(row["id"]) + for row in await conn.fetch( + "SELECT id FROM exams WHERE title LIKE $1", f"{PREFIX}_%" + ) + ] + if exam_id is not None and exam_id not in exam_ids: + exam_ids.append(exam_id) + session_ids: list[int] = [] + if user_ids or exam_ids: + rows = await conn.fetch( + """ + SELECT id FROM exam_sessions + WHERE ($1::int[] = '{}'::int[] OR user_id = ANY($1::int[])) + OR ($2::int[] = '{}'::int[] OR exam_id = ANY($2::int[])) + """, + user_ids or [], + exam_ids or [], + ) + session_ids = [int(row["id"]) for row in rows] + if session_ids: + await conn.execute("DELETE FROM answers WHERE session_id = ANY($1::int[])", session_ids) + await conn.execute("DELETE FROM exam_logs WHERE session_id = ANY($1::int[])", session_ids) + await conn.execute("DELETE FROM exam_sessions WHERE id = ANY($1::int[])", session_ids) + rds.delete(*[f"exam_session:{sid}" for sid in session_ids]) + for eid in exam_ids: + await conn.execute( + "DELETE FROM question_options WHERE question_id IN (SELECT id FROM questions WHERE exam_id=$1)", + eid, + ) + await conn.execute("DELETE FROM questions WHERE exam_id=$1", eid) + await conn.execute("DELETE FROM exams WHERE id=$1", eid) + rds.delete(f"monitoring:delta:exam:{eid}") + rds.delete(f"cache:exam-start-validation:v1:{eid}") + rds.delete(f"cache:exam-start-validation:v1:{eid}:lock") + rds.delete(f"exam:{eid}:questions:payload:v1") + if user_ids: + await conn.execute("DELETE FROM users WHERE id = ANY($1::int[])", user_ids) + + +async def seed(conn: asyncpg.Connection, count: int, seb_key: str) -> dict[str, Any]: + now = datetime.now(timezone.utc) + teacher_id = int( + await conn.fetchval( + """ + INSERT INTO users (username, password_hash, full_name, role, is_active) + VALUES ($1, 'x', 'Go Stage0 Teacher', 'teacher', true) + RETURNING id + """, + f"{PREFIX}_teacher", + ) + ) + student_ids: list[int] = [] + usernames: list[str] = [] + for index in range(count): + username = f"{PREFIX}_s{index:03d}" + user_id = int( + await conn.fetchval( + """ + INSERT INTO users (username, password_hash, full_name, role, student_class, is_active) + VALUES ($1, 'x', $1, 'student', $2, true) + RETURNING id + """, + username, + CLASS_NAME, + ) + ) + student_ids.append(user_id) + usernames.append(username) + exam_id = int( + await conn.fetchval( + """ + INSERT INTO exams ( + title, creator_id, duration_minutes, start_time, end_time, max_attempts, + shuffle_questions, shuffle_options, show_results, seb_config_key, + is_published, subject, exam_type, show_teacher_name, allowed_classes, + is_deleted, has_ever_had_results + ) VALUES ( + $1, $2, 90, $3, $4, 3, true, true, false, $5, true, 'MTK', 'UTS', true, $6, + false, false + ) + RETURNING id + """, + f"{PREFIX}_EXAM", + teacher_id, + now - timedelta(hours=1), + now + timedelta(hours=8), + seb_key, + CLASS_NAME, + ) + ) + for index in range(4): + question_id = int( + await conn.fetchval( + """ + INSERT INTO questions ( + exam_id, question_text, question_type, difficulty_level, points, order_index, question_settings + ) VALUES ($1, $2, 'multiple_choice', 'hard', 1, $3, '{}'::jsonb) + RETURNING id + """, + exam_id, + f"GO Q{index + 1}", + index, + ) + ) + for option_index, letter in enumerate("ABCD"): + await conn.execute( + """ + INSERT INTO question_options (question_id, option_text, is_correct, order_index, option_group) + VALUES ($1, $2, $3, $4, 'standard') + """, + question_id, + f"{letter}{index + 1}", + option_index == 0, + option_index, + ) + return {"exam_id": exam_id, "student_ids": student_ids, "usernames": usernames} + + +def start_one(base: str, exam_id: int, token: str, seb_key: str) -> dict[str, Any]: + started = time.perf_counter() + response = httpx.post( + f"{base.rstrip('/')}/api/exams/{exam_id}/start", + headers=seb_headers(token, seb_key), + timeout=30.0, + ) + elapsed_ms = (time.perf_counter() - started) * 1000 + body: dict[str, Any] = {} + try: + body = response.json() + except Exception: + body = {"raw": response.text[:300]} + return { + "status": response.status_code, + "elapsed_ms": elapsed_ms, + "replica": response.headers.get("X-SIAB-Replica", ""), + "body": body, + "text": response.text[:300], + } + + +def admission_snapshot(base: str) -> dict[str, Any]: + response = httpx.get(f"{base.rstrip('/')}/internal/start-admission", timeout=5.0) + response.raise_for_status() + return response.json() + + +async def verify_phase( + conn: asyncpg.Connection, + rds: redis.Redis, + exam_id: int, + user_ids: list[int], + results: list[dict[str, Any]], +) -> list[str]: + errors: list[str] = [] + if any(item["status"] != 200 for item in results): + errors.append( + "http " + + ",".join(f"{item['status']}" for item in results if item["status"] != 200) + ) + session_ids = [ + int(item["body"]["session_id"]) + for item in results + if item["status"] == 200 and item["body"].get("session_id") + ] + if len(session_ids) != len(user_ids): + errors.append(f"session_ids={len(session_ids)} users={len(user_ids)}") + if len(set(session_ids)) != len(session_ids): + errors.append("duplicate session_id in HTTP responses") + rows = await conn.fetch( + """ + SELECT user_id, count(*)::int AS sessions, + count(*) FILTER (WHERE status IN ('in_progress', 'active'))::int AS live + FROM exam_sessions + WHERE exam_id=$1 AND user_id = ANY($2::int[]) + GROUP BY user_id + """, + exam_id, + user_ids, + ) + by_user = {int(row["user_id"]): row for row in rows} + for user_id in user_ids: + row = by_user.get(user_id) + if row is None or int(row["sessions"]) != 1 or int(row["live"]) != 1: + errors.append(f"session row user={user_id} {dict(row) if row else None}") + start_logs = int( + await conn.fetchval( + """ + SELECT count(*) FROM exam_logs l + JOIN exam_sessions s ON s.id = l.session_id + WHERE s.exam_id=$1 AND s.user_id = ANY($2::int[]) + AND l.event_type='SESSION_START' + """, + exam_id, + user_ids, + ) + or 0 + ) + if start_logs != len(user_ids): + errors.append(f"SESSION_START={start_logs}") + json_bad = int( + await conn.fetchval( + """ + SELECT count(*) FROM exam_logs l + JOIN exam_sessions s ON s.id = l.session_id + WHERE s.exam_id=$1 AND s.user_id = ANY($2::int[]) + AND l.event_type='SESSION_START' + AND jsonb_typeof(l.event_data) IS DISTINCT FROM 'object' + """, + exam_id, + user_ids, + ) + or 0 + ) + if json_bad: + errors.append(f"json_encoding_errors={json_bad}") + missing_redis = 0 + for session_id in session_ids: + raw = rds.get(f"exam_session:{session_id}") + if not raw: + missing_redis += 1 + continue + snapshot = json.loads(raw) + if snapshot.get("status") != "in_progress": + errors.append(f"redis status session={session_id}") + if missing_redis: + errors.append(f"missing_redis={missing_redis}") + stream = rds.xlen(f"monitoring:delta:exam:{exam_id}") + if int(stream or 0) < len(user_ids): + errors.append(f"monitoring={stream}") + return errors + + +async def run_resume_and_race( + conn: asyncpg.Connection, + base: str, + exam_id: int, + user_id: int, + token: str, + seb_key: str, +) -> list[str]: + errors: list[str] = [] + first = start_one(base, exam_id, token, seb_key) + second = start_one(base, exam_id, token, seb_key) + if first["status"] != 200 or second["status"] != 200: + errors.append(f"resume http {first['status']} {second['status']}") + return errors + if first["body"].get("session_id") != second["body"].get("session_id"): + errors.append("resume created a new session") + with ThreadPoolExecutor(max_workers=2) as pool: + futs = [pool.submit(start_one, base, exam_id, token, seb_key) for _ in range(2)] + raced = [fut.result() for fut in futs] + live = int( + await conn.fetchval( + """ + SELECT count(*) FROM exam_sessions + WHERE user_id=$1 AND exam_id=$2 AND status IN ('in_progress', 'active') + """, + user_id, + exam_id, + ) + or 0 + ) + starts = int( + await conn.fetchval( + """ + SELECT count(*) FROM exam_logs l + JOIN exam_sessions s ON s.id = l.session_id + WHERE s.user_id=$1 AND s.exam_id=$2 AND l.event_type='SESSION_START' + """, + user_id, + exam_id, + ) + or 0 + ) + if any(item["status"] != 200 for item in raced) or live != 1 or starts != 1: + errors.append(f"race live={live} starts={starts} http={[item['status'] for item in raced]}") + return errors + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Stage-0 synthetic START against Go") + parser.add_argument("--go-url", default=os.getenv("GO_START_URL", "http://127.0.0.1:18001")) + parser.add_argument( + "--start-url", + default=os.getenv("GO_START_HTTP_URL", ""), + help="Public START base (nginx). Defaults to --go-url.", + ) + parser.add_argument("--database-url", default=os.getenv("DATABASE_URL", "")) + parser.add_argument("--redis-url", default=os.getenv("REDIS_URL", "redis://127.0.0.1:56379/0")) + parser.add_argument("--jwt-secret", default=os.getenv("JWT_SECRET_KEY", "")) + parser.add_argument("--seb-key", default=os.getenv("SEB_DEFAULT_CONFIG_KEY", "go-stage0-seb")) + parser.add_argument("--phases", default=",".join(str(item) for item in PHASES)) + parser.add_argument( + "--allow-mixed-replica", + action="store_true", + help="Allow FastAPI replica headers when START is split by nginx.", + ) + return parser.parse_args() + + +async def amain() -> dict[str, Any]: + args = parse_args() + if not args.database_url or not args.jwt_secret: + raise SystemExit("DATABASE_URL and JWT_SECRET_KEY are required") + phases = [int(item) for item in args.phases.split(",") if item.strip()] + start_url = (args.start_url or args.go_url).rstrip("/") + rds = redis.Redis.from_url(args.redis_url, decode_responses=True) + conn = await connect_pg(args.database_url) + report: dict[str, Any] = {"phases": [], "errors": [], "start_url": start_url} + try: + await cleanup(conn, rds, None) + fixture = await seed(conn, sum(phases), args.seb_key) + tokens = [ + mint_token(user_id, username, args.jwt_secret) + for user_id, username in zip(fixture["student_ids"], fixture["usernames"]) + ] + health = httpx.get(f"{args.go_url.rstrip('/')}/health", timeout=5.0) + if health.status_code != 200: + raise RuntimeError(f"go health {health.status_code}") + report["runtime"] = health.json() if health.headers.get("content-type", "").startswith("application/json") else {} + cursor = 0 + peak_holders = 0 + for count in phases: + slice_ids = fixture["student_ids"][cursor : cursor + count] + slice_tokens = tokens[cursor : cursor + count] + cursor += count + with ThreadPoolExecutor(max_workers=count) as pool: + futs = [ + pool.submit(start_one, start_url, fixture["exam_id"], token, args.seb_key) + for token in slice_tokens + ] + results = [fut.result() for fut in futs] + snapshot = admission_snapshot(args.go_url) + peak_holders = max(peak_holders, int(snapshot.get("peak_holders") or 0)) + errors = await verify_phase(conn, rds, fixture["exam_id"], slice_ids, results) + if int(snapshot.get("peak_holders") or 0) > 4: + errors.append(f"peak_holders={snapshot.get('peak_holders')}") + go_count = sum(item["replica"] == "go-start" for item in results) + fastapi_count = count - go_count + elapsed = sorted(item["elapsed_ms"] for item in results) + def pct(p: float) -> float: + if not elapsed: + return 0.0 + idx = min(len(elapsed) - 1, max(0, int(round((p / 100) * (len(elapsed) - 1))))) + return round(elapsed[idx], 2) + if not args.allow_mixed_replica: + if any(item["replica"] != "go-start" for item in results): + errors.append("unexpected replica header") + report["phases"].append( + { + "users": count, + "success": sum(item["status"] == 200 for item in results), + "go_start": go_count, + "fastapi_start": fastapi_count, + "replica": sorted({item["replica"] for item in results}), + "p95_ms": pct(95), + "p99_ms": pct(99), + "admission": snapshot, + "errors": errors, + } + ) + report["errors"].extend(errors) + resume_user = fixture["student_ids"][0] + report["resume_race"] = await run_resume_and_race( + conn, + start_url, + fixture["exam_id"], + resume_user, + tokens[0], + args.seb_key, + ) + report["errors"].extend(report["resume_race"]) + report["peak_holders"] = peak_holders + report["cleanup"] = "pending" + await cleanup(conn, rds, fixture["exam_id"]) + leftover_users = int( + await conn.fetchval("SELECT count(*) FROM users WHERE username LIKE $1", f"{PREFIX}_%") + or 0 + ) + leftover_exams = int( + await conn.fetchval("SELECT count(*) FROM exams WHERE title LIKE $1", f"{PREFIX}_%") + or 0 + ) + report["cleanup"] = "PASS" if leftover_users == 0 and leftover_exams == 0 else "FAIL" + if report["cleanup"] != "PASS": + report["errors"].append(f"leftovers users={leftover_users} exams={leftover_exams}") + finally: + await conn.close() + report["verdict"] = "PASS" if not report["errors"] else "FAIL" + print(json.dumps(report, default=str)) + return report + + +def main() -> int: + result = asyncio.run(amain()) + return 0 if result.get("verdict") == "PASS" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/go_submit_canary_control.sh b/scripts/go_submit_canary_control.sh new file mode 100755 index 0000000..e260ede --- /dev/null +++ b/scripts/go_submit_canary_control.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MODE="${1:-status}" +COMPOSE_FILE="${COMPOSE_FILE:-$ROOT/docker-compose.production.yml}" +PROJECT="${COMPOSE_PROJECT:-siab1}" +RUNTIME="$ROOT/runtime_control/nginx.submit-canary.conf" +OFF="$ROOT/docker/nginx.submit-canary-off.conf" +MODES=(5pct 10pct 25pct 50pct 75pct 100pct) + +compose() { + docker compose -p "$PROJECT" -f "$COMPOSE_FILE" "$@" +} + +reload_nginx() { + compose exec -T nginx nginx -t + compose exec -T nginx nginx -s reload +} + +mode_file() { + local name="$1" + if [ "$name" = "off" ]; then + printf "%s\n" "$OFF" + return + fi + printf "%s\n" "$ROOT/docker/nginx.submit-canary-${name}.conf" +} + +current_mode() { + if cmp -s "$RUNTIME" "$OFF"; then + echo "off" + return + fi + local name + for name in "${MODES[@]}"; do + if cmp -s "$RUNTIME" "$(mode_file "$name")"; then + echo "$name" + return + fi + done + echo "unknown" +} + +apply_mode() { + local name="$1" + local src + src="$(mode_file "$name")" + if [ ! -f "$src" ]; then + echo "missing $src" >&2 + exit 2 + fi + cat "$src" > "$RUNTIME" + reload_nginx + printf "submit_canary_mode=%s\n" "$(current_mode)" +} + +case "$MODE" in + status) + printf "submit_canary_mode=%s\n" "$(current_mode)" + ;; + off|rollback) + apply_mode off + ;; + 5pct|10pct|25pct|50pct|75pct|100pct) + apply_mode "$MODE" + ;; + *) + echo "usage: $0 status|off|rollback|5pct|10pct|25pct|50pct|75pct|100pct" >&2 + exit 2 + ;; +esac diff --git a/scripts/run_answer_parity_ab.py b/scripts/run_answer_parity_ab.py new file mode 100644 index 0000000..b7933c1 --- /dev/null +++ b/scripts/run_answer_parity_ab.py @@ -0,0 +1,614 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import subprocess +import time +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any +from urllib.parse import urlparse, urlunparse + +import asyncpg +import httpx +import jwt +import redis as redislib + + +ROOT = Path(__file__).resolve().parents[1] +PYTHON = "/tmp/opencode/siab1-venv/bin/python" +GO = "/tmp/opencode/go/bin/go" +PG = "postgresql://fahmiagent@127.0.0.1:55432/postgres" +PGBOUNCER = "postgresql://fahmiagent@pgbouncer.localhost:56432/postgres" +REDIS_URL = "redis://127.0.0.1:56379/0" +JWT_SECRET = "answer-parity-jwt-key-32-bytes-bb" +SECRET_KEY = "answer-parity-secret-key-32-bytes-aa" +PREFIX = "GOANS" +CLASS_NAME = "XII-ANS" +FA_PORT = 18200 +GO_PORT = 18201 +PB_PORT = 56432 +WORKDIR = Path("/tmp/opencode/siab1-answer") + + +def mint(user_id: int, username: str, active: bool = True) -> str: + now = datetime.now(timezone.utc) + return jwt.encode( + { + "sub": str(user_id), + "username": username, + "role": "student", + "full_name": username, + "student_class": CLASS_NAME, + "is_active": active, + "exp": int((now + timedelta(hours=2)).timestamp()), + }, + JWT_SECRET, + algorithm="HS256", + ) + + +SEB_KEY = "ans-seb" +SEB_HASH = hashlib.sha256(SEB_KEY.encode()).hexdigest() + + +def headers(token: str | None) -> dict[str, str]: + out = { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "SEB/3.6 (Safe Exam Browser)", + "X-SafeExamBrowser-ConfigKeyHash": SEB_HASH, + } + if token: + out["Authorization"] = f"Bearer {token}" + return out + + +def post_answer(base: str, token: str | None, body: dict[str, Any]) -> httpx.Response: + return httpx.post( + f"{base}/api/exams/submit-answer", + headers=headers(token), + json=body, + timeout=30.0, + ) + + +def wait_http(url: str, timeout: float = 30.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + try: + if httpx.get(url, timeout=1.0).status_code == 200: + return + except Exception: + time.sleep(0.1) + raise RuntimeError(f"timeout {url}") + + +def env_for(runtime: str) -> dict[str, str]: + env = os.environ.copy() + db = ( + PGBOUNCER.replace("postgresql://", "postgresql+asyncpg://", 1) + + "?prepared_statement_cache_size=0" + if runtime == "fastapi" + else PGBOUNCER + "?pool_max_conns=4&default_query_exec_mode=simple_protocol&statement_cache_capacity=0" + ) + env.update( + { + "APP_ENV": "development", + "DEBUG": "false", + "DATABASE_URL": db, + "REDIS_URL": REDIS_URL, + "SECRET_KEY": SECRET_KEY, + "JWT_SECRET_KEY": JWT_SECRET, + "DISABLE_RATE_LIMIT": "true", + "ENFORCE_SXB": "false", + "EXAM_PEAK_MODE": "true", + "ANSWER_WRITE_MODE": "direct", + "TELEGRAM_ALERTING_ENABLED": "false", + "TELEGRAM_ENABLED": "false", + "PYTHONUNBUFFERED": "1", + "PYTHONPATH": str(ROOT), + "DB_USE_NULL_POOL_WITH_PGBOUNCER": "true", + "SIAB_REPLICA": f"answer-{runtime}", + "PYTHON_UPSTREAM": "", + "PORT": str(GO_PORT if runtime == "go" else FA_PORT), + } + ) + return env + + +def start_pgbouncer() -> subprocess.Popen: + WORKDIR.mkdir(parents=True, exist_ok=True) + auth = WORKDIR / "userlist.txt" + auth.write_text('"fahmiagent" ""\n', encoding="utf-8") + ini = WORKDIR / "pgbouncer.ini" + ini.write_text( + f""" +[databases] +postgres = host=127.0.0.1 port=55432 dbname=postgres +[pgbouncer] +listen_addr = * +listen_port = {PB_PORT} +auth_type = trust +auth_file = {auth} +pool_mode = transaction +max_client_conn = 2000 +default_pool_size = 40 +admin_users = fahmiagent +logfile = {WORKDIR / "pgbouncer.log"} +pidfile = {WORKDIR / "pgbouncer.pid"} +unix_socket_dir = +""", + encoding="utf-8", + ) + proc = subprocess.Popen( + ["/tmp/opencode/pgbouncer-root/usr/sbin/pgbouncer", str(ini)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + deadline = time.time() + 10 + while time.time() < deadline: + try: + import socket + + sock = socket.create_connection(("127.0.0.1", PB_PORT), 0.2) + sock.close() + return proc + except Exception: + if proc.poll() is not None: + break + time.sleep(0.1) + raise RuntimeError("pgbouncer failed") + + +async def cleanup(conn: asyncpg.Connection) -> None: + user_ids = [int(r["id"]) for r in await conn.fetch("SELECT id FROM users WHERE username LIKE $1", f"{PREFIX}_%")] + exam_ids = [int(r["id"]) for r in await conn.fetch("SELECT id FROM exams WHERE title LIKE $1", f"{PREFIX}_%")] + sids: list[int] = [] + if user_ids or exam_ids: + sessions = await conn.fetch( + """ + SELECT id FROM exam_sessions + WHERE ($1::int[] = '{}'::int[] OR user_id = ANY($1::int[])) + OR ($2::int[] = '{}'::int[] OR exam_id = ANY($2::int[])) + """, + user_ids or [], + exam_ids or [], + ) + sids = [int(r["id"]) for r in sessions] + if sids: + await conn.execute("DELETE FROM answers WHERE session_id = ANY($1::int[])", sids) + await conn.execute("DELETE FROM exam_logs WHERE session_id = ANY($1::int[])", sids) + await conn.execute("DELETE FROM exam_sessions WHERE id = ANY($1::int[])", sids) + for eid in exam_ids: + await conn.execute( + "DELETE FROM question_options WHERE question_id IN (SELECT id FROM questions WHERE exam_id=$1)", + eid, + ) + await conn.execute("DELETE FROM questions WHERE exam_id=$1", eid) + await conn.execute("DELETE FROM exams WHERE id=$1", eid) + if user_ids: + await conn.execute("DELETE FROM users WHERE id = ANY($1::int[])", user_ids) + + +async def insert_user(conn: asyncpg.Connection, username: str) -> int: + return int( + await conn.fetchval( + """ + INSERT INTO users (username, password_hash, full_name, role, student_class, is_active) + VALUES ($1, 'x', $1, 'student', $2, true) + RETURNING id + """, + username, + CLASS_NAME, + ) + ) + + +def compare(name: str, fa: httpx.Response, go: httpx.Response, expect: int) -> dict[str, Any]: + try: + fa_json = fa.json() + except Exception: + fa_json = fa.text[:200] + try: + go_json = go.json() + except Exception: + go_json = go.text[:200] + ok = fa.status_code == go.status_code == expect + if expect == 200: + ok = ok and fa_json == go_json + elif expect == 422: + ok = fa.status_code == go.status_code == 422 + elif isinstance(fa_json, dict) and isinstance(go_json, dict): + ok = ok and fa_json.get("detail") == go_json.get("detail") + return { + "name": name, + "ok": ok, + "fastapi": {"status": fa.status_code, "body": fa_json}, + "go": {"status": go.status_code, "body": go_json}, + } + + +def burst(base: str, jobs: list[tuple[str, dict[str, Any]]]) -> dict[str, Any]: + import threading + + barrier = threading.Barrier(len(jobs)) + + def one(job: tuple[str, dict[str, Any]]) -> tuple[int, float]: + barrier.wait() + started = time.perf_counter() + response = post_answer(base, job[0], job[1]) + return response.status_code, (time.perf_counter() - started) * 1000 + + with ThreadPoolExecutor(max_workers=len(jobs)) as pool: + rows = list(pool.map(one, jobs)) + elapsed = sorted(ms for _, ms in rows) + + def pct(p: float) -> float: + if not elapsed: + return 0.0 + idx = min(len(elapsed) - 1, max(0, int(round((p / 100) * (len(elapsed) - 1))))) + return round(elapsed[idx], 2) + + statuses: dict[str, int] = {} + for code, _ in rows: + statuses[str(code)] = statuses.get(str(code), 0) + 1 + return { + "n": len(jobs), + "success": sum(code == 200 for code, _ in rows), + "statuses": statuses, + "p50": pct(50), + "p95": pct(95), + "p99": pct(99), + "throughput": round(len(jobs) / (max(elapsed) / 1000) if elapsed else 0, 2), + } + + +def rss_kb(pid: int) -> int: + try: + for line in Path(f"/proc/{pid}/status").read_text().splitlines(): + if line.startswith("VmRSS:"): + return int(line.split()[1]) + except Exception: + return 0 + return 0 + + +def cpu_seconds(pid: int) -> float: + try: + parts = Path(f"/proc/{pid}/stat").read_text().split() + return (int(parts[13]) + int(parts[14])) / os.sysconf("SC_CLK_TCK") + except Exception: + return 0.0 + + +def main() -> int: + WORKDIR.mkdir(parents=True, exist_ok=True) + pb = start_pgbouncer() + fa_log = open(WORKDIR / "fastapi.log", "w") + go_log = open(WORKDIR / "go.log", "w") + go_bin = WORKDIR / "go-server" + subprocess.check_call( + [GO, "build", "-o", str(go_bin), "./cmd/server"], + cwd=str(ROOT / "go"), + env={**os.environ, "CGO_ENABLED": "0"}, + ) + fa = subprocess.Popen( + [PYTHON, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(FA_PORT), "--log-level", "warning"], + cwd=str(ROOT), + env=env_for("fastapi"), + stdout=fa_log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + go = subprocess.Popen( + [str(go_bin)], + cwd=str(ROOT), + env=env_for("go"), + stdout=go_log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + report: dict[str, Any] = {"cases": [], "ab": []} + rds = redislib.Redis.from_url(REDIS_URL, decode_responses=True) + try: + wait_http(f"http://127.0.0.1:{FA_PORT}/health") + wait_http(f"http://127.0.0.1:{GO_PORT}/health") + fa_base = f"http://127.0.0.1:{FA_PORT}" + go_base = f"http://127.0.0.1:{GO_PORT}" + + async def seed_and_parity() -> None: + conn = await asyncpg.connect(PG, statement_cache_size=0) + try: + await cleanup(conn) + await conn.execute("UPDATE system_settings SET allow_browser_testing = true") + await conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS answers_session_question_uidx ON answers (session_id, question_id)" + ) + teacher = int( + await conn.fetchval( + """ + INSERT INTO users (username, password_hash, full_name, role, is_active) + VALUES ($1, 'x', $1, 'teacher', true) RETURNING id + """, + f"{PREFIX}_teacher", + ) + ) + student = await insert_user(conn, f"{PREFIX}_s") + other = await insert_user(conn, f"{PREFIX}_other") + now = datetime.now(timezone.utc) + exam_id = int( + await conn.fetchval( + """ + INSERT INTO exams ( + title, creator_id, duration_minutes, start_time, end_time, max_attempts, + shuffle_questions, shuffle_options, show_results, seb_config_key, + is_published, subject, exam_type, show_teacher_name, access_token, + is_deleted, has_ever_had_results + ) VALUES ( + $1, $2, 90, $3, $4, 3, false, false, false, 'ans-seb', + true, 'MTK', 'UTS', true, 'ANS001', false, false + ) RETURNING id + """, + f"{PREFIX}_exam", + teacher, + now - timedelta(hours=1), + now + timedelta(hours=3), + ) + ) + q_mc = int( + await conn.fetchval( + """ + INSERT INTO questions (exam_id, question_text, question_type, difficulty_level, points, order_index, question_settings) + VALUES ($1, 'MC', 'multiple_choice', 'easy', 1, 0, '{}'::jsonb) RETURNING id + """, + exam_id, + ) + ) + opt_ok = int( + await conn.fetchval( + "INSERT INTO question_options (question_id, option_text, is_correct, order_index) VALUES ($1,'A',true,0) RETURNING id", + q_mc, + ) + ) + opt_bad = int( + await conn.fetchval( + "INSERT INTO question_options (question_id, option_text, is_correct, order_index) VALUES ($1,'B',false,1) RETURNING id", + q_mc, + ) + ) + q_complex = int( + await conn.fetchval( + """ + INSERT INTO questions (exam_id, question_text, question_type, difficulty_level, points, order_index, question_settings) + VALUES ($1, 'CX', 'multiple_choice_complex', 'easy', 2, 1, '{"pgk_type":"checkbox"}'::jsonb) RETURNING id + """, + exam_id, + ) + ) + cx1 = int( + await conn.fetchval( + "INSERT INTO question_options (question_id, option_text, is_correct, order_index) VALUES ($1,'C1',true,0) RETURNING id", + q_complex, + ) + ) + cx2 = int( + await conn.fetchval( + "INSERT INTO question_options (question_id, option_text, is_correct, order_index) VALUES ($1,'C2',true,1) RETURNING id", + q_complex, + ) + ) + q_table = int( + await conn.fetchval( + """ + INSERT INTO questions (exam_id, question_text, question_type, pgk_type, difficulty_level, points, order_index, question_settings) + VALUES ($1, 'TB', 'multiple_choice_complex', 'table_validation', 'easy', 2, 2, + '{"pgk_type":"table_validation","statement_answers":[true,false]}'::jsonb) + RETURNING id + """, + exam_id, + ) + ) + q_essay = int( + await conn.fetchval( + """ + INSERT INTO questions (exam_id, question_text, question_type, difficulty_level, points, order_index, question_settings) + VALUES ($1, 'ES', 'essay', 'easy', 5, 3, '{}'::jsonb) RETURNING id + """, + exam_id, + ) + ) + session = int( + await conn.fetchval( + """ + INSERT INTO exam_sessions (user_id, exam_id, status, start_time) + VALUES ($1, $2, 'in_progress', $3) RETURNING id + """, + student, + exam_id, + now, + ) + ) + submitted = int( + await conn.fetchval( + """ + INSERT INTO exam_sessions (user_id, exam_id, status, start_time, end_time) + VALUES ($1, $2, 'submitted', $3, $3) RETURNING id + """, + student, + exam_id, + now, + ) + ) + paused = int( + await conn.fetchval( + """ + INSERT INTO exam_sessions (user_id, exam_id, status, start_time) + VALUES ($1, $2, 'paused', $3) RETURNING id + """, + student, + exam_id, + now, + ) + ) + other_session = int( + await conn.fetchval( + """ + INSERT INTO exam_sessions (user_id, exam_id, status, start_time) + VALUES ($1, $2, 'in_progress', $3) RETURNING id + """, + other, + exam_id, + now, + ) + ) + tok = mint(student, f"{PREFIX}_s") + other_tok = mint(other, f"{PREFIX}_other") + answers_before = int(await conn.fetchval("SELECT count(*) FROM answers WHERE session_id=$1", session) or 0) + cases = [ + ("first", tok, {"session_id": session, "question_id": q_mc, "selected_option_id": opt_ok}, 200), + ("update", tok, {"session_id": session, "question_id": q_mc, "selected_option_id": opt_bad}, 200), + ("idempotent", tok, {"session_id": session, "question_id": q_mc, "selected_option_id": opt_bad}, 200), + ("invalid_question", tok, {"session_id": session, "question_id": 999999, "selected_option_id": opt_ok}, 404), + ("invalid_session", tok, {"session_id": 999999, "question_id": q_mc, "selected_option_id": opt_ok}, 404), + ("ownership", other_tok, {"session_id": session, "question_id": q_mc, "selected_option_id": opt_ok}, 404), + ("submitted", tok, {"session_id": submitted, "question_id": q_mc, "selected_option_id": opt_ok}, 200), + ("expired", tok, {"session_id": paused, "question_id": q_mc, "selected_option_id": opt_ok}, 400), + ("complex", tok, {"session_id": session, "question_id": q_complex, "selected_option_ids": [cx1, cx2]}, 200), + ("table", tok, {"session_id": session, "question_id": q_table, "statement_answers": {"0": True, "1": False}}, 200), + ("essay", tok, {"session_id": session, "question_id": q_essay, "answer_text": "esai"}, 200), + ("malformed", tok, None, 422), + ("missing_auth", None, {"session_id": session, "question_id": q_mc, "selected_option_id": opt_ok}, 401), + ] + for name, token, body, expect in cases: + if body is None: + fa = httpx.post(f"{fa_base}/api/exams/submit-answer", headers=headers(token), content=b"{", timeout=30) + go = httpx.post(f"{go_base}/api/exams/submit-answer", headers=headers(token), content=b"{", timeout=30) + else: + fa = post_answer(fa_base, token, body) + go = post_answer(go_base, token, body) + report["cases"].append(compare(name, fa, go, expect)) + + def conc(base: str) -> list[int]: + with ThreadPoolExecutor(max_workers=8) as pool: + futs = [ + pool.submit(post_answer, base, tok, {"session_id": session, "question_id": q_mc, "selected_option_id": opt_ok}) + for _ in range(8) + ] + return [fut.result().status_code for fut in futs] + + fa_c = conc(fa_base) + go_c = conc(go_base) + report["cases"].append( + { + "name": "simultaneous_same_question", + "ok": fa_c == go_c and all(code == 200 for code in fa_c + go_c), + "fastapi": fa_c, + "go": go_c, + } + ) + with ThreadPoolExecutor(max_workers=4) as pool: + fa_d = list( + pool.map( + lambda body: post_answer(fa_base, tok, body).status_code, + [ + {"session_id": session, "question_id": q_complex, "selected_option_ids": [cx1, cx2]}, + {"session_id": session, "question_id": q_essay, "answer_text": "esai2"}, + ], + ) + ) + with ThreadPoolExecutor(max_workers=4) as pool: + go_d = list( + pool.map( + lambda body: post_answer(go_base, tok, body).status_code, + [ + {"session_id": session, "question_id": q_complex, "selected_option_ids": [cx1, cx2]}, + {"session_id": session, "question_id": q_essay, "answer_text": "esai2"}, + ], + ) + ) + report["cases"].append( + { + "name": "different_questions_concurrent", + "ok": fa_d == go_d and all(code == 200 for code in fa_d + go_d), + "fastapi": fa_d, + "go": go_d, + } + ) + answers_after = int(await conn.fetchval("SELECT count(*) FROM answers WHERE session_id=$1", session) or 0) + redis_fa = rds.get(f"exam_answers:{session}") + report["db"] = {"answers_before": answers_before, "answers_after": answers_after, "ok": answers_after >= 4} + report["redis"] = {"exam_answers": redis_fa is not None, "ok": True} + report["other_session"] = other_session + report["q_mc"] = q_mc + report["opt_ok"] = opt_ok + report["exam_id"] = exam_id + report["student"] = student + finally: + await conn.close() + + asyncio.run(seed_and_parity()) + failed = [item for item in report["cases"] if not item.get("ok")] + report["parity_pass"] = not failed and report.get("db", {}).get("ok") + if not report["parity_pass"]: + report["failed"] = failed + print(json.dumps(report, default=str)) + return 1 + + async def seed_ab(n: int, runtime: str) -> list[tuple[str, dict[str, Any]]]: + conn = await asyncpg.connect(PG, statement_cache_size=0) + try: + now = datetime.now(timezone.utc) + exam_id = int(report["exam_id"]) + q_mc = int(report["q_mc"]) + opt_ok = int(report["opt_ok"]) + jobs: list[tuple[str, dict[str, Any]]] = [] + for i in range(n): + uid = await insert_user(conn, f"{PREFIX}_ab{n}_{runtime}_{i}") + sid = int( + await conn.fetchval( + """ + INSERT INTO exam_sessions (user_id, exam_id, status, start_time) + VALUES ($1, $2, 'in_progress', $3) RETURNING id + """, + uid, + exam_id, + now, + ) + ) + jobs.append((mint(uid, f"{PREFIX}_ab{n}_{runtime}_{i}"), {"session_id": sid, "question_id": q_mc, "selected_option_id": opt_ok})) + return jobs + finally: + await conn.close() + + for n in (50, 200, 620): + for runtime, base, proc in (("fastapi", fa_base, fa), ("go", go_base, go)): + jobs = asyncio.run(seed_ab(n, runtime)) + cpu0 = cpu_seconds(proc.pid) + rss0 = rss_kb(proc.pid) + result = burst(base, jobs) + result["runtime"] = runtime + result["cpu_per_1000"] = round((cpu_seconds(proc.pid) - cpu0) / max(n, 1) * 1000, 4) + result["peak_rss_kb"] = max(rss0, rss_kb(proc.pid)) + report["ab"].append(result) + if result["success"] != n: + print(json.dumps(report, default=str)) + return 1 + print(json.dumps(report, default=str)) + return 0 + finally: + for proc in (fa, go, pb): + try: + os.killpg(proc.pid, 15) + except Exception: + proc.terminate() + fa_log.close() + go_log.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_join_parity_ab.py b/scripts/run_join_parity_ab.py new file mode 100644 index 0000000..7bd439a --- /dev/null +++ b/scripts/run_join_parity_ab.py @@ -0,0 +1,610 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import signal +import statistics +import subprocess +import time +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +import asyncpg +import httpx +import jwt +import redis as redislib + + +ROOT = Path(__file__).resolve().parents[1] +PYTHON = "/tmp/opencode/siab1-venv/bin/python" +GO = "/tmp/opencode/go/bin/go" +PG = "postgresql://fahmiagent@127.0.0.1:55432/postgres" +PGBOUNCER = "postgresql://fahmiagent@pgbouncer.localhost:56432/postgres" +REDIS_URL = "redis://127.0.0.1:56379/0" +JWT_SECRET = "join-parity-jwt-key-32-bytes-bbbb" +SECRET_KEY = "join-parity-secret-key-32-bytes-aa" +PREFIX = "GOJOIN" +CLASS_NAME = "XII-JOIN" +FA_PORT = 18100 +GO_PORT = 18101 +PB_PORT = 56432 +WORKDIR = Path("/tmp/opencode/siab1-join") + + +def mint(user_id: int, username: str, role: str, student_class: str, active: bool = True) -> str: + now = datetime.now(timezone.utc) + return jwt.encode( + { + "sub": str(user_id), + "username": username, + "role": role, + "full_name": username, + "student_class": student_class, + "is_active": active, + "exp": int((now + timedelta(hours=2)).timestamp()), + }, + JWT_SECRET, + algorithm="HS256", + ) + + +def headers(token: str | None) -> dict[str, str]: + out = {"Accept": "application/json", "Content-Type": "application/json"} + if token: + out["Authorization"] = f"Bearer {token}" + return out + + +def join_post(base: str, token: str | None, body: str) -> httpx.Response: + return httpx.post( + f"{base}/api/exams/join", + headers=headers(token), + content=body.encode(), + timeout=30.0, + ) + + +def wait_http(url: str, timeout: float = 30.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + try: + if httpx.get(url, timeout=1.0).status_code == 200: + return + except Exception: + time.sleep(0.1) + raise RuntimeError(f"timeout {url}") + + +def env_for(runtime: str) -> dict[str, str]: + env = os.environ.copy() + db = ( + PGBOUNCER.replace("postgresql://", "postgresql+asyncpg://", 1) + + "?prepared_statement_cache_size=0" + if runtime == "fastapi" + else PGBOUNCER + "?pool_max_conns=4&default_query_exec_mode=simple_protocol&statement_cache_capacity=0" + ) + env.update( + { + "APP_ENV": "development", + "DEBUG": "false", + "DATABASE_URL": db, + "REDIS_URL": REDIS_URL, + "SECRET_KEY": SECRET_KEY, + "JWT_SECRET_KEY": JWT_SECRET, + "DISABLE_RATE_LIMIT": "true", + "ENFORCE_SXB": "false", + "TELEGRAM_ALERTING_ENABLED": "false", + "TELEGRAM_ENABLED": "false", + "PYTHONUNBUFFERED": "1", + "PYTHONPATH": str(ROOT), + "DB_USE_NULL_POOL_WITH_PGBOUNCER": "true", + "SIAB_REPLICA": f"join-{runtime}", + "PYTHON_UPSTREAM": "", + "PORT": str(GO_PORT if runtime == "go" else FA_PORT), + } + ) + return env + + +def start_pgbouncer() -> subprocess.Popen: + WORKDIR.mkdir(parents=True, exist_ok=True) + auth = WORKDIR / "userlist.txt" + auth.write_text('"fahmiagent" ""\n', encoding="utf-8") + ini = WORKDIR / "pgbouncer.ini" + ini.write_text( + f""" +[databases] +postgres = host=127.0.0.1 port=55432 dbname=postgres +[pgbouncer] +listen_addr = * +listen_port = {PB_PORT} +auth_type = trust +auth_file = {auth} +pool_mode = transaction +max_client_conn = 2000 +default_pool_size = 40 +admin_users = fahmiagent +logfile = {WORKDIR / "pgbouncer.log"} +pidfile = {WORKDIR / "pgbouncer.pid"} +unix_socket_dir = +""", + encoding="utf-8", + ) + proc = subprocess.Popen( + ["/tmp/opencode/pgbouncer-root/usr/sbin/pgbouncer", str(ini)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + deadline = time.time() + 10 + while time.time() < deadline: + try: + httpx.Response # keep import used + import socket + + sock = socket.create_connection(("127.0.0.1", PB_PORT), 0.2) + sock.close() + return proc + except Exception: + if proc.poll() is not None: + break + time.sleep(0.1) + raise RuntimeError("pgbouncer failed") + + +async def cleanup(conn: asyncpg.Connection) -> None: + user_ids = [int(r["id"]) for r in await conn.fetch("SELECT id FROM users WHERE username LIKE $1", f"{PREFIX}_%")] + exam_ids = [int(r["id"]) for r in await conn.fetch("SELECT id FROM exams WHERE title LIKE $1", f"{PREFIX}_%")] + if user_ids or exam_ids: + sessions = await conn.fetch( + """ + SELECT id FROM exam_sessions + WHERE ($1::int[] = '{}'::int[] OR user_id = ANY($1::int[])) + OR ($2::int[] = '{}'::int[] OR exam_id = ANY($2::int[])) + """, + user_ids or [], + exam_ids or [], + ) + sids = [int(r["id"]) for r in sessions] + if sids: + await conn.execute("DELETE FROM answers WHERE session_id = ANY($1::int[])", sids) + await conn.execute("DELETE FROM exam_logs WHERE session_id = ANY($1::int[])", sids) + await conn.execute("DELETE FROM exam_sessions WHERE id = ANY($1::int[])", sids) + for eid in exam_ids: + await conn.execute( + "DELETE FROM question_options WHERE question_id IN (SELECT id FROM questions WHERE exam_id=$1)", + eid, + ) + await conn.execute("DELETE FROM questions WHERE exam_id=$1", eid) + await conn.execute("DELETE FROM exams WHERE id=$1", eid) + if user_ids: + await conn.execute("DELETE FROM users WHERE id = ANY($1::int[])", user_ids) + + +async def insert_user(conn: asyncpg.Connection, username: str, role: str, cls: str | None, active: bool = True) -> int: + return int( + await conn.fetchval( + """ + INSERT INTO users (username, password_hash, full_name, role, student_class, is_active) + VALUES ($1, 'x', $1, $2, $3, $4) + RETURNING id + """, + username, + role, + cls, + active, + ) + ) + + +async def insert_exam( + conn: asyncpg.Connection, + *, + creator_id: int, + token: str, + published: bool, + start_delta: timedelta, + end_delta: timedelta, + classes: str | None, + students: str | None, + max_attempts: int = 3, +) -> int: + now = datetime.now(timezone.utc) + exam_id = int( + await conn.fetchval( + """ + INSERT INTO exams ( + title, creator_id, duration_minutes, start_time, end_time, max_attempts, + shuffle_questions, shuffle_options, show_results, seb_config_key, + is_published, subject, exam_type, show_teacher_name, allowed_classes, + allowed_students, access_token, is_deleted, has_ever_had_results + ) VALUES ( + $1, $2, 90, $3, $4, $5, false, false, false, 'join-seb', + $6, 'MTK', 'UTS', true, $7, $8, $9, false, false + ) + RETURNING id + """, + f"{PREFIX}_{token}", + creator_id, + now + start_delta, + now + end_delta, + max_attempts, + published, + classes, + students, + token, + ) + ) + for index in range(3): + await conn.execute( + """ + INSERT INTO questions (exam_id, question_text, question_type, difficulty_level, points, order_index, question_settings) + VALUES ($1, $2, 'multiple_choice', 'easy', 1, $3, '{}'::jsonb) + """, + exam_id, + f"Q{index+1}", + index, + ) + return exam_id + + +def compare_case(name: str, fa: httpx.Response, go: httpx.Response, expect_status: int) -> dict[str, Any]: + fa_json: Any + go_json: Any + try: + fa_json = fa.json() + except Exception: + fa_json = fa.text[:200] + try: + go_json = go.json() + except Exception: + go_json = go.text[:200] + ok = fa.status_code == go.status_code == expect_status + if expect_status == 200: + ok = ok and fa_json == go_json + elif expect_status == 422: + ok = ok and fa.status_code == go.status_code == 422 + elif isinstance(fa_json, dict) and isinstance(go_json, dict): + ok = ok and fa_json.get("detail") == go_json.get("detail") + return { + "name": name, + "ok": ok, + "fastapi": {"status": fa.status_code, "body": fa_json}, + "go": {"status": go.status_code, "body": go_json}, + } + + +def burst(base: str, tokens: list[str], exam_token: str) -> dict[str, Any]: + barrier = threading_barrier(len(tokens)) + + def one(tok: str) -> tuple[int, float]: + barrier.wait() + started = time.perf_counter() + response = join_post(base, tok, json.dumps({"token": exam_token})) + return response.status_code, (time.perf_counter() - started) * 1000 + + with ThreadPoolExecutor(max_workers=len(tokens)) as pool: + rows = list(pool.map(one, tokens)) + elapsed = sorted(ms for _, ms in rows) + def pct(p: float) -> float: + if not elapsed: + return 0.0 + idx = min(len(elapsed) - 1, max(0, int(round((p / 100) * (len(elapsed) - 1))))) + return round(elapsed[idx], 2) + success = sum(code == 200 for code, _ in rows) + statuses: dict[str, int] = {} + for code, _ in rows: + statuses[str(code)] = statuses.get(str(code), 0) + 1 + return { + "n": len(tokens), + "success": success, + "statuses": statuses, + "p50": pct(50), + "p95": pct(95), + "p99": pct(99), + "throughput": round(len(tokens) / (max(elapsed) / 1000) if elapsed else 0, 2), + } + + +def threading_barrier(n: int): + import threading + + return threading.Barrier(n) + + +def rss_kb(pid: int) -> int: + try: + for line in Path(f"/proc/{pid}/status").read_text().splitlines(): + if line.startswith("VmRSS:"): + return int(line.split()[1]) + except Exception: + return 0 + return 0 + + +def cpu_seconds(pid: int) -> float: + try: + parts = Path(f"/proc/{pid}/stat").read_text().split() + return (int(parts[13]) + int(parts[14])) / os.sysconf("SC_CLK_TCK") + except Exception: + return 0.0 + + +def main() -> int: + import asyncio + + WORKDIR.mkdir(parents=True, exist_ok=True) + pb = start_pgbouncer() + fa_log = open(WORKDIR / "fastapi.log", "w") + go_log = open(WORKDIR / "go.log", "w") + go_bin = WORKDIR / "go-server" + subprocess.check_call( + [GO, "build", "-o", str(go_bin), "./cmd/server"], + cwd=str(ROOT / "go"), + env={**os.environ, "CGO_ENABLED": "0"}, + ) + fa = subprocess.Popen( + [PYTHON, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(FA_PORT), "--log-level", "warning"], + cwd=str(ROOT), + env=env_for("fastapi"), + stdout=fa_log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + go = subprocess.Popen( + [str(go_bin)], + cwd=str(ROOT), + env=env_for("go"), + stdout=go_log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + report: dict[str, Any] = {"cases": [], "ab": []} + rds = redislib.Redis.from_url(REDIS_URL, decode_responses=True) + try: + wait_http(f"http://127.0.0.1:{FA_PORT}/health") + wait_http(f"http://127.0.0.1:{GO_PORT}/health") + fa_base = f"http://127.0.0.1:{FA_PORT}" + go_base = f"http://127.0.0.1:{GO_PORT}" + + async def seed_and_parity() -> None: + conn = await asyncpg.connect(PG, statement_cache_size=0) + try: + await cleanup(conn) + teacher = await insert_user(conn, f"{PREFIX}_teacher", "teacher", None) + developer = await insert_user(conn, f"{PREFIX}_dev", "developer", None) + student = await insert_user(conn, f"{PREFIX}_s_ok", "student", CLASS_NAME) + inactive = await insert_user(conn, f"{PREFIX}_s_off", "student", CLASS_NAME, False) + other_class = await insert_user(conn, f"{PREFIX}_s_class", "student", "XII-B") + listed = await insert_user(conn, f"{PREFIX}_s_list", "student", CLASS_NAME) + unlisted = await insert_user(conn, f"{PREFIX}_s_unlisted", "student", CLASS_NAME) + teacher_user = await insert_user(conn, f"{PREFIX}_trole", "teacher", None) + admin_user = await insert_user(conn, f"{PREFIX}_admin", "admin", None) + guru = await insert_user(conn, f"{PREFIX}_guru", "guruplus", "GuruPlus") + concurrent_ids = [ + await insert_user(conn, f"{PREFIX}_c{i}", "student", CLASS_NAME) for i in range(8) + ] + valid = await insert_exam( + conn, creator_id=teacher, token="JOIN01", published=True, + start_delta=timedelta(hours=-1), end_delta=timedelta(hours=8), + classes=None, students=None, + ) + unpublished = await insert_exam( + conn, creator_id=teacher, token="JOIN02", published=False, + start_delta=timedelta(hours=-1), end_delta=timedelta(hours=8), + classes=None, students=None, + ) + future = await insert_exam( + conn, creator_id=teacher, token="JOIN03", published=True, + start_delta=timedelta(hours=2), end_delta=timedelta(hours=8), + classes=None, students=None, + ) + ended = await insert_exam( + conn, creator_id=teacher, token="JOIN04", published=True, + start_delta=timedelta(hours=-8), end_delta=timedelta(hours=-1), + classes=None, students=None, + ) + class_exam = await insert_exam( + conn, creator_id=teacher, token="JOIN05", published=True, + start_delta=timedelta(hours=-1), end_delta=timedelta(hours=8), + classes=CLASS_NAME, students=None, + ) + list_exam = await insert_exam( + conn, creator_id=teacher, token="JOIN06", published=True, + start_delta=timedelta(hours=-1), end_delta=timedelta(hours=8), + classes=None, students=str(listed), + ) + guru_teacher_exam = await insert_exam( + conn, creator_id=teacher, token="JOIN07", published=True, + start_delta=timedelta(hours=-1), end_delta=timedelta(hours=8), + classes="GuruPlus", students=None, + ) + guru_dev_exam = await insert_exam( + conn, creator_id=developer, token="JOIN08", published=True, + start_delta=timedelta(hours=-1), end_delta=timedelta(hours=8), + classes="GuruPlus", students=None, + ) + sessions_before = int(await conn.fetchval("SELECT count(*) FROM exam_sessions") or 0) + tok_ok = mint(student, f"{PREFIX}_s_ok", "student", CLASS_NAME) + cases = [ + ("valid", tok_ok, json.dumps({"token": "JOIN01"}), 200), + ("invalid_token", tok_ok, json.dumps({"token": "ZZZZZZ"}), 404), + ("unpublished", tok_ok, json.dumps({"token": "JOIN02"}), 403), + ("before_start", tok_ok, json.dumps({"token": "JOIN03"}), 403), + ("ended", tok_ok, json.dumps({"token": "JOIN04"}), 403), + ( + "inactive", + mint(inactive, f"{PREFIX}_s_off", "student", CLASS_NAME, False), + json.dumps({"token": "JOIN01"}), + 403, + ), + ("missing_auth", None, json.dumps({"token": "JOIN01"}), 401), + ("allowed_class", tok_ok, json.dumps({"token": "JOIN05"}), 200), + ( + "forbidden_class", + mint(other_class, f"{PREFIX}_s_class", "student", "XII-B"), + json.dumps({"token": "JOIN05"}), + 403, + ), + ( + "allowed_student", + mint(listed, f"{PREFIX}_s_list", "student", CLASS_NAME), + json.dumps({"token": "JOIN06"}), + 200, + ), + ( + "forbidden_student", + mint(unlisted, f"{PREFIX}_s_unlisted", "student", CLASS_NAME), + json.dumps({"token": "JOIN06"}), + 403, + ), + ( + "teacher_role", + mint(teacher_user, f"{PREFIX}_trole", "teacher", ""), + json.dumps({"token": "JOIN01"}), + 403, + ), + ( + "admin_role", + mint(admin_user, f"{PREFIX}_admin", "admin", ""), + json.dumps({"token": "JOIN01"}), + 403, + ), + ( + "guruplus_teacher_exam", + mint(guru, f"{PREFIX}_guru", "guruplus", "GuruPlus"), + json.dumps({"token": "JOIN07"}), + 403, + ), + ( + "guruplus_developer_exam", + mint(guru, f"{PREFIX}_guru", "guruplus", "GuruPlus"), + json.dumps({"token": "JOIN08"}), + 200, + ), + ("malformed", tok_ok, "{", 422), + ] + for name, token, body, status in cases: + fa_resp = join_post(fa_base, token, body) + go_resp = join_post(go_base, token, body) + report["cases"].append(compare_case(name, fa_resp, go_resp, status)) + invalid_jwt = join_post(fa_base, "not-a-jwt", json.dumps({"token": "JOIN01"})) + invalid_jwt_go = join_post(go_base, "not-a-jwt", json.dumps({"token": "JOIN01"})) + report["cases"].append(compare_case("invalid_auth", invalid_jwt, invalid_jwt_go, 401)) + first = join_post(fa_base, tok_ok, json.dumps({"token": "JOIN01"})) + second = join_post(go_base, tok_ok, json.dumps({"token": "JOIN01"})) + report["cases"].append(compare_case("repeated", first, second, 200)) + await conn.execute( + """ + INSERT INTO exam_sessions ( + user_id, exam_id, start_time, status, seb_detected, is_secure_app_verified, + violation_count, emergency_exit_allowed, terminated_by_admin, is_paused, total_paused_seconds + ) VALUES ($1, $2, NOW(), 'in_progress', true, true, 0, false, false, false, 0) + """, + student, + valid, + ) + existing_fa = join_post(fa_base, tok_ok, json.dumps({"token": "JOIN01"})) + existing_go = join_post(go_base, tok_ok, json.dumps({"token": "JOIN01"})) + report["cases"].append(compare_case("existing_session", existing_fa, existing_go, 200)) + + def conc(base: str) -> list[int]: + tokens = [ + mint(uid, f"{PREFIX}_c{i}", "student", CLASS_NAME) + for i, uid in enumerate(concurrent_ids) + ] + with ThreadPoolExecutor(max_workers=8) as pool: + futs = [ + pool.submit(join_post, base, tok, json.dumps({"token": "JOIN01"})) + for tok in tokens + ] + return [fut.result().status_code for fut in futs] + + fa_codes = conc(fa_base) + go_codes = conc(go_base) + report["cases"].append( + { + "name": "concurrent", + "ok": fa_codes == [200] * 8 and go_codes == [200] * 8, + "fastapi": fa_codes, + "go": go_codes, + } + ) + sessions_after = int(await conn.fetchval("SELECT count(*) FROM exam_sessions") or 0) + report["db"] = { + "sessions_delta": sessions_after - sessions_before, + "ok": sessions_after - sessions_before == 1, + } + exam_keys = list(rds.scan_iter("exam_session:*")) + monitor_keys = [key for key in rds.scan_iter("monitoring:*") if PREFIX in key or str(valid) in key] + report["redis"] = {"exam_session_keys": len(exam_keys), "ok": True} + ab_ids = [] + for i in range(620): + uid = await insert_user(conn, f"{PREFIX}_ab{i:03d}", "student", CLASS_NAME) + ab_ids.append(uid) + ab_exam = await insert_exam( + conn, creator_id=teacher, token="JOINAB", published=True, + start_delta=timedelta(hours=-1), end_delta=timedelta(hours=8), + classes=None, students=None, + ) + report["ab_exam_id"] = ab_exam + report["ab_tokens"] = [ + mint(uid, f"{PREFIX}_ab{i:03d}", "student", CLASS_NAME) for i, uid in enumerate(ab_ids) + ] + finally: + await conn.close() + + asyncio.run(seed_and_parity()) + tokens: list[str] = report.pop("ab_tokens") + for n in (50, 200, 620): + slice_tokens = tokens[:n] + for runtime, base, proc in (("fastapi", fa_base, fa), ("go", go_base, go)): + cpu0 = cpu_seconds(proc.pid) + rss0 = rss_kb(proc.pid) + result = burst(base, slice_tokens, "JOINAB") + cpu1 = cpu_seconds(proc.pid) + rss1 = rss_kb(proc.pid) + result.update( + { + "runtime": runtime, + "cpu_per_1000": round(((cpu1 - cpu0) / n) * 1000, 4) if n else 0, + "peak_rss_kb": max(rss0, rss1), + } + ) + report["ab"].append(result) + if result["success"] != n: + report["ab_fail"] = result + break + else: + continue + break + report["parity_pass"] = all(case["ok"] for case in report["cases"]) and report["db"]["ok"] + finally: + for proc in (fa, go, pb): + if proc.poll() is None: + try: + os.killpg(proc.pid, signal.SIGTERM) if proc is not pb else proc.terminate() + except Exception: + proc.terminate() + fa_log.close() + go_log.close() + async def wipe() -> None: + conn = await asyncpg.connect(PG, statement_cache_size=0) + try: + await cleanup(conn) + finally: + await conn.close() + import asyncio as _aio + _aio.run(wipe()) + (WORKDIR / "report.json").write_text(json.dumps(report, default=str, indent=2), encoding="utf-8") + print(json.dumps({k: report[k] for k in ("parity_pass", "db", "redis", "ab") if k in report}, default=str)) + failed = [c["name"] for c in report.get("cases", []) if not c.get("ok")] + if failed: + print("FAILED_CASES", failed) + return 0 if report.get("parity_pass") and not report.get("ab_fail") else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_remaining_hotpath_parity_ab.py b/scripts/run_remaining_hotpath_parity_ab.py new file mode 100644 index 0000000..1e9a9d9 --- /dev/null +++ b/scripts/run_remaining_hotpath_parity_ab.py @@ -0,0 +1,639 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import subprocess +import time +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +import asyncpg +import httpx +import jwt +import redis as redislib + +ROOT = Path(__file__).resolve().parents[1] +PYTHON = "/tmp/opencode/siab1-venv/bin/python" +GO = "/tmp/opencode/go/bin/go" +PG = "postgresql://fahmiagent@127.0.0.1:55432/postgres" +REDIS_URL = "redis://127.0.0.1:56379/0" +JWT_SECRET = "remain-parity-jwt-key-32-bytes-bb" +SECRET_KEY = "remain-parity-secret-key-32-bytes-aa" +PREFIX = "GOREM" +CLASS_NAME = "XII-REM" +FA_PORT = 18300 +GO_PORT = 18301 +WORKDIR = Path("/tmp/opencode/siab1-remain") +SEB_KEY = "rem-seb" +SEB_HASH = hashlib.sha256(SEB_KEY.encode()).hexdigest() + + +def mint(user_id: int, username: str) -> str: + now = datetime.now(timezone.utc) + return jwt.encode( + { + "sub": str(user_id), + "username": username, + "role": "student", + "full_name": username, + "student_class": CLASS_NAME, + "is_active": True, + "exp": int((now + timedelta(hours=2)).timestamp()), + }, + JWT_SECRET, + algorithm="HS256", + ) + + +def headers(token: str | None, seb: bool = False) -> dict[str, str]: + out = { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "SEB/3.6 (Safe Exam Browser)" if seb else "Mozilla/5.0", + } + if seb: + out["X-SafeExamBrowser-ConfigKeyHash"] = SEB_HASH + if token: + out["Authorization"] = f"Bearer {token}" + return out + + +def wait_http(url: str, timeout: float = 40.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + try: + if httpx.get(url, timeout=1.0).status_code == 200: + return + except Exception: + time.sleep(0.1) + raise RuntimeError(f"timeout {url}") + + +def env_for(runtime: str) -> dict[str, str]: + env = os.environ.copy() + db = ( + PG.replace("postgresql://", "postgresql+asyncpg://", 1) + if runtime == "fastapi" + else PG + "?pool_max_conns=4&default_query_exec_mode=simple_protocol&statement_cache_capacity=0" + ) + env.update( + { + "APP_ENV": "development", + "DEBUG": "false", + "DATABASE_URL": db, + "REDIS_URL": REDIS_URL, + "SECRET_KEY": SECRET_KEY, + "JWT_SECRET_KEY": JWT_SECRET, + "DISABLE_RATE_LIMIT": "true", + "ENFORCE_SXB": "false", + "EXAM_PEAK_MODE": "true", + "ANSWER_WRITE_MODE": "direct", + "TELEGRAM_ALERTING_ENABLED": "false", + "TELEGRAM_ENABLED": "false", + "PYTHONUNBUFFERED": "1", + "PYTHONPATH": str(ROOT), + "SIAB_REPLICA": f"remain-{runtime}", + "PYTHON_UPSTREAM": "", + "PORT": str(GO_PORT if runtime == "go" else FA_PORT), + } + ) + return env + + +async def cleanup(conn: asyncpg.Connection) -> None: + user_ids = [int(r["id"]) for r in await conn.fetch("SELECT id FROM users WHERE username LIKE $1", f"{PREFIX}_%")] + exam_ids = [int(r["id"]) for r in await conn.fetch("SELECT id FROM exams WHERE title LIKE $1", f"{PREFIX}_%")] + sids: list[int] = [] + if user_ids or exam_ids: + sessions = await conn.fetch( + """ + SELECT id FROM exam_sessions + WHERE ($1::int[] = '{}'::int[] OR user_id = ANY($1::int[])) + OR ($2::int[] = '{}'::int[] OR exam_id = ANY($2::int[])) + """, + user_ids or [], + exam_ids or [], + ) + sids = [int(r["id"]) for r in sessions] + if sids: + await conn.execute("DELETE FROM answers WHERE session_id = ANY($1::int[])", sids) + await conn.execute("DELETE FROM exam_logs WHERE session_id = ANY($1::int[])", sids) + await conn.execute("DELETE FROM exam_sessions WHERE id = ANY($1::int[])", sids) + for eid in exam_ids: + await conn.execute( + "DELETE FROM question_options WHERE question_id IN (SELECT id FROM questions WHERE exam_id=$1)", eid + ) + await conn.execute("DELETE FROM questions WHERE exam_id=$1", eid) + await conn.execute("DELETE FROM exams WHERE id=$1", eid) + if user_ids: + await conn.execute("DELETE FROM users WHERE id = ANY($1::int[])", user_ids) + + +async def insert_user(conn: asyncpg.Connection, username: str) -> int: + return int( + await conn.fetchval( + """ + INSERT INTO users (username, password_hash, full_name, role, student_class, is_active) + VALUES ($1, 'x', $1, 'student', $2, true) RETURNING id + """, + username, + CLASS_NAME, + ) + ) + + +def post(base: str, path: str, token: str | None, body: Any, raw: bytes | None = None, seb: bool = False) -> httpx.Response: + if raw is not None: + return httpx.post(f"{base}{path}", headers=headers(token, seb=seb), content=raw, timeout=30.0) + return httpx.post(f"{base}{path}", headers=headers(token, seb=seb), json=body, timeout=180.0) + + +def compare_status(name: str, fa: httpx.Response, go: httpx.Response, expect: int) -> dict[str, Any]: + try: + fa_json = fa.json() + except Exception: + fa_json = fa.text[:200] + try: + go_json = go.json() + except Exception: + go_json = go.text[:200] + ok = fa.status_code == go.status_code == expect + if expect in {401, 403, 404, 400} and isinstance(fa_json, dict) and isinstance(go_json, dict): + ok = ok and fa_json.get("detail") == go_json.get("detail") + elif expect == 200 and isinstance(fa_json, dict) and isinstance(go_json, dict): + skip = {"timestamp", "queue_id", "session_id"} + + def canon(value: Any) -> Any: + if isinstance(value, bool) or value is None: + return value + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, dict): + return {k: canon(v) for k, v in value.items() if k not in skip} + return value + + ok = ok and canon(fa_json) == canon(go_json) + elif expect == 422: + ok = fa.status_code == go.status_code == 422 + return {"name": name, "ok": ok, "fastapi": {"status": fa.status_code, "body": fa_json}, "go": {"status": go.status_code, "body": go_json}} + + +def burst(base: str, path: str, jobs: list[tuple[str, dict[str, Any]]], seb: bool = False) -> dict[str, Any]: + import threading + + barrier = threading.Barrier(len(jobs)) + + def one(job: tuple[str, dict[str, Any]]) -> tuple[int, float]: + barrier.wait() + started = time.perf_counter() + try: + response = post(base, path, job[0], job[1], seb=seb) + return response.status_code, (time.perf_counter() - started) * 1000 + except Exception: + return 0, (time.perf_counter() - started) * 1000 + + with ThreadPoolExecutor(max_workers=len(jobs)) as pool: + rows = list(pool.map(one, jobs)) + elapsed = sorted(ms for _, ms in rows) + + def pct(p: float) -> float: + if not elapsed: + return 0.0 + idx = min(len(elapsed) - 1, max(0, int(round((p / 100) * (len(elapsed) - 1))))) + return round(elapsed[idx], 2) + + return { + "n": len(jobs), + "success": sum(code == 200 for code, _ in rows), + "p50": pct(50), + "p95": pct(95), + "p99": pct(99), + "throughput": round(len(jobs) / (max(elapsed) / 1000) if elapsed else 0, 2), + } + + +def rss_kb(pid: int) -> int: + try: + for line in Path(f"/proc/{pid}/status").read_text().splitlines(): + if line.startswith("VmRSS:"): + return int(line.split()[1]) + except Exception: + return 0 + return 0 + + +def cpu_seconds(pid: int) -> float: + try: + parts = Path(f"/proc/{pid}/stat").read_text().split() + return (int(parts[13]) + int(parts[14])) / os.sysconf("SC_CLK_TCK") + except Exception: + return 0.0 + + +def main() -> int: + WORKDIR.mkdir(parents=True, exist_ok=True) + fa_log = open(WORKDIR / "fastapi.log", "w") + go_log = open(WORKDIR / "go.log", "w") + go_bin = WORKDIR / "go-server" + subprocess.check_call( + [GO, "build", "-o", str(go_bin), "./cmd/server"], + cwd=str(ROOT / "go"), + env={**os.environ, "CGO_ENABLED": "0", "PATH": os.environ.get("PATH", "")}, + ) + fa_proc = subprocess.Popen( + [PYTHON, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(FA_PORT), "--log-level", "warning"], + cwd=str(ROOT), + env=env_for("fastapi"), + stdout=fa_log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + go_proc = subprocess.Popen( + [str(go_bin)], + cwd=str(ROOT), + env=env_for("go"), + stdout=go_log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + report: dict[str, Any] = {"autosave": {"cases": []}, "batch": {"cases": []}, "submit": {"cases": []}, "ab": []} + rds = redislib.Redis.from_url(REDIS_URL, decode_responses=True) + try: + wait_http(f"http://127.0.0.1:{FA_PORT}/health") + wait_http(f"http://127.0.0.1:{GO_PORT}/health") + fa_base = f"http://127.0.0.1:{FA_PORT}" + go_base = f"http://127.0.0.1:{GO_PORT}" + + async def seed() -> dict[str, Any]: + conn = await asyncpg.connect(PG, statement_cache_size=0) + try: + await cleanup(conn) + await conn.execute("UPDATE system_settings SET allow_browser_testing = true") + await conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS answers_session_question_uidx ON answers (session_id, question_id)" + ) + teacher = int( + await conn.fetchval( + "INSERT INTO users (username, password_hash, full_name, role, is_active) VALUES ($1,'x',$1,'teacher',true) RETURNING id", + f"{PREFIX}_teacher", + ) + ) + now = datetime.now(timezone.utc) + exam_id = int( + await conn.fetchval( + """ + INSERT INTO exams ( + title, creator_id, duration_minutes, start_time, end_time, max_attempts, + shuffle_questions, shuffle_options, show_results, passing_score, seb_config_key, + is_published, subject, exam_type, show_teacher_name, access_token, + is_deleted, has_ever_had_results + ) VALUES ( + $1,$2,90,$3,$4,3,false,false,true,70,$5,true,'MTK','UTS',true,'REM001',false,false + ) RETURNING id + """, + f"{PREFIX}_exam", + teacher, + now - timedelta(hours=1), + now + timedelta(hours=3), + SEB_KEY, + ) + ) + q_mc = int( + await conn.fetchval( + "INSERT INTO questions (exam_id, question_text, question_type, difficulty_level, points, order_index, question_settings) VALUES ($1,'MC','multiple_choice','easy',1,0,'{}'::jsonb) RETURNING id", + exam_id, + ) + ) + opt_ok = int( + await conn.fetchval( + "INSERT INTO question_options (question_id, option_text, is_correct, order_index) VALUES ($1,'A',true,0) RETURNING id", + q_mc, + ) + ) + q_cx = int( + await conn.fetchval( + "INSERT INTO questions (exam_id, question_text, question_type, difficulty_level, points, order_index, question_settings) VALUES ($1,'CX','multiple_choice_complex','easy',2,1,'{\"pgk_type\":\"checkbox\"}'::jsonb) RETURNING id", + exam_id, + ) + ) + cx1 = int( + await conn.fetchval( + "INSERT INTO question_options (question_id, option_text, is_correct, order_index) VALUES ($1,'C1',true,0) RETURNING id", + q_cx, + ) + ) + q_table = int( + await conn.fetchval( + """ + INSERT INTO questions (exam_id, question_text, question_type, pgk_type, difficulty_level, points, order_index, question_settings) + VALUES ($1,'TB','multiple_choice_complex','table_validation','easy',2,2,'{"pgk_type":"table_validation","statement_answers":[true,false]}'::jsonb) + RETURNING id + """, + exam_id, + ) + ) + q_essay = int( + await conn.fetchval( + "INSERT INTO questions (exam_id, question_text, question_type, difficulty_level, points, order_index, question_settings) VALUES ($1,'ES','essay','easy',5,3,'{}'::jsonb) RETURNING id", + exam_id, + ) + ) + student = await insert_user(conn, f"{PREFIX}_s") + student_fa = await insert_user(conn, f"{PREFIX}_sfa") + student_go = await insert_user(conn, f"{PREFIX}_sgo") + other = await insert_user(conn, f"{PREFIX}_o") + session = int( + await conn.fetchval( + "INSERT INTO exam_sessions (user_id, exam_id, status, start_time) VALUES ($1,$2,'in_progress',$3) RETURNING id", + student, + exam_id, + now, + ) + ) + session_fa = int( + await conn.fetchval( + "INSERT INTO exam_sessions (user_id, exam_id, status, start_time) VALUES ($1,$2,'in_progress',$3) RETURNING id", + student_fa, + exam_id, + now, + ) + ) + session_go = int( + await conn.fetchval( + "INSERT INTO exam_sessions (user_id, exam_id, status, start_time) VALUES ($1,$2,'in_progress',$3) RETURNING id", + student_go, + exam_id, + now, + ) + ) + submitted = int( + await conn.fetchval( + "INSERT INTO exam_sessions (user_id, exam_id, status, start_time, end_time, score) VALUES ($1,$2,'submitted',$3,$3,80) RETURNING id", + student, + exam_id, + now, + ) + ) + paused = int( + await conn.fetchval( + "INSERT INTO exam_sessions (user_id, exam_id, status, start_time) VALUES ($1,$2,'paused',$3) RETURNING id", + student, + exam_id, + now, + ) + ) + other_session = int( + await conn.fetchval( + "INSERT INTO exam_sessions (user_id, exam_id, status, start_time) VALUES ($1,$2,'in_progress',$3) RETURNING id", + other, + exam_id, + now, + ) + ) + return { + "exam_id": exam_id, + "q_mc": q_mc, + "opt_ok": opt_ok, + "q_cx": q_cx, + "cx1": cx1, + "q_table": q_table, + "q_essay": q_essay, + "student": student, + "student_fa": student_fa, + "student_go": student_go, + "other": other, + "session": session, + "session_fa": session_fa, + "session_go": session_go, + "submitted": submitted, + "paused": paused, + "other_session": other_session, + } + finally: + await conn.close() + + ids = asyncio.run(seed()) + tok = mint(ids["student"], f"{PREFIX}_s") + tok_fa = mint(ids["student_fa"], f"{PREFIX}_sfa") + tok_go = mint(ids["student_go"], f"{PREFIX}_sgo") + other_tok = mint(ids["other"], f"{PREFIX}_o") + ts = datetime.now(timezone.utc).isoformat() + auto_ok = {"session_id": ids["session"], "answers": {str(ids["q_mc"]): ids["opt_ok"]}, "timestamp": ts} + + autosave_cases = [ + ("valid", tok, auto_ok, 200), + ("repeat", tok, auto_ok, 200), + ("invalid_session", tok, {"session_id": 999999, "answers": {"1": 1}, "timestamp": ts}, 404), + ("ownership", other_tok, {"session_id": ids["session"], "answers": {str(ids["q_mc"]): ids["opt_ok"]}, "timestamp": ts}, 404), + ("submitted", tok, {"session_id": ids["submitted"], "answers": {str(ids["q_mc"]): ids["opt_ok"]}, "timestamp": ts}, 404), + ("expired", tok, {"session_id": ids["paused"], "answers": {str(ids["q_mc"]): ids["opt_ok"]}, "timestamp": ts}, 404), + ("malformed", tok, None, 422), + ("missing_auth", None, auto_ok, 401), + ("seb_whitelist", tok, auto_ok, 200), + ] + answers_before = None + conn_sync = None + for name, token, body, expect in autosave_cases: + if body is None: + fa = post(fa_base, "/api/exams/auto-save", token, None, raw=b"{") + go = post(go_base, "/api/exams/auto-save", token, None, raw=b"{") + else: + fa = post(fa_base, "/api/exams/auto-save", token, body) + go = post(go_base, "/api/exams/auto-save", token, body) + report["autosave"]["cases"].append(compare_status(name, fa, go, expect)) + fa_redis = json.loads(rds.get(f"exam_answers:{ids['session']}") or "null") + go_ttl = rds.ttl(f"exam_answers:{ids['session']}") + report["autosave"]["redis"] = {"payload": fa_redis, "ttl": go_ttl, "ok": fa_redis is not None and go_ttl > 0} + + async def count_answers() -> int: + conn = await asyncpg.connect(PG, statement_cache_size=0) + try: + return int(await conn.fetchval("SELECT count(*) FROM answers WHERE session_id=$1", ids["session"]) or 0) + finally: + await conn.close() + + answers_before = asyncio.run(count_answers()) + report["autosave"]["db_unchanged"] = {"count": answers_before, "ok": answers_before == 0} + + def batch_body_for(session_id: int) -> dict[str, Any]: + return { + "session_id": session_id, + "answers": [ + {"question_id": ids["q_mc"], "selected_option_id": ids["opt_ok"]}, + {"question_id": ids["q_cx"], "selected_option_ids": [ids["cx1"]]}, + ], + } + + write_batch = [ + ("single", {"answers": [{"question_id": ids["q_mc"], "selected_option_id": ids["opt_ok"]}]}), + ("multiple", {"answers": batch_body_for(0)["answers"]}), + ("essay", {"answers": [{"question_id": ids["q_essay"], "answer_text": "esai"}]}), + ("table", {"answers": [{"question_id": ids["q_table"], "statement_answers": {"0": True, "1": False}}]}), + ("duplicate_items", {"answers": [{"question_id": ids["q_mc"], "selected_option_id": ids["opt_ok"]}, {"question_id": ids["q_mc"], "selected_option_id": ids["opt_ok"]}]}), + ] + for name, payload in write_batch: + fa_body = {"session_id": ids["session_fa"], **payload} + go_body = {"session_id": ids["session_go"], **payload} + fa_resp = post(fa_base, "/api/exams/auto-save-batch", tok_fa, fa_body) + go_resp = post(go_base, "/api/exams/auto-save-batch", tok_go, go_body) + report["batch"]["cases"].append(compare_status(name, fa_resp, go_resp, 200)) + fa_repeat = post(fa_base, "/api/exams/auto-save-batch", tok_fa, {"session_id": ids["session_fa"], **write_batch[1][1]}) + go_repeat = post(go_base, "/api/exams/auto-save-batch", tok_go, {"session_id": ids["session_go"], **write_batch[1][1]}) + report["batch"]["cases"].append(compare_status("repeat", fa_repeat, go_repeat, 200)) + error_batch = [ + ("invalid_question", tok, {"session_id": ids["session"], "answers": [{"question_id": 999999, "selected_option_id": 1}]}, 200), + ("ownership", other_tok, {"session_id": ids["session"], "answers": [{"question_id": ids["q_mc"], "selected_option_id": ids["opt_ok"]}]}, 404), + ("submitted", tok, {"session_id": ids["submitted"], "answers": [{"question_id": ids["q_mc"], "selected_option_id": ids["opt_ok"]}]}, 404), + ("malformed", tok, None, 422), + ("missing_auth", None, batch_body_for(ids["session"]), 401), + ] + for name, token, body, expect in error_batch: + if body is None: + fa_resp = post(fa_base, "/api/exams/auto-save-batch", token, None, raw=b"{") + go_resp = post(go_base, "/api/exams/auto-save-batch", token, None, raw=b"{") + else: + fa_resp = post(fa_base, "/api/exams/auto-save-batch", token, body) + go_resp = post(go_base, "/api/exams/auto-save-batch", token, body) + report["batch"]["cases"].append(compare_status(name, fa_resp, go_resp, expect)) + + def conc_batch(base: str) -> list[int]: + with ThreadPoolExecutor(max_workers=8) as pool: + futs = [ + pool.submit(post, base, "/api/exams/auto-save-batch", tok, {"session_id": ids["session"], "answers": [{"question_id": ids["q_mc"], "selected_option_id": ids["opt_ok"]}]}) + for _ in range(8) + ] + return [fut.result().status_code for fut in futs] + + fa_c = conc_batch(fa_base) + go_c = conc_batch(go_base) + report["batch"]["cases"].append( + {"name": "concurrent", "ok": all(code == 200 for code in fa_c + go_c), "fastapi": fa_c, "go": go_c} + ) + after = asyncio.run(count_answers()) + report["batch"]["db"] = {"answers": after, "ok": after >= 1, "lost": after == 0} + + fa_seb = post(fa_base, "/api/exams/submit", tok_fa, {"session_id": ids["session_fa"]}, seb=False) + go_seb = post(go_base, "/api/exams/submit", tok_go, {"session_id": ids["session_go"]}, seb=False) + report["submit"]["cases"].append({ + "name": "seb_reject", + "ok": fa_seb.status_code == go_seb.status_code == 403, + "fastapi": {"status": fa_seb.status_code}, + "go": {"status": go_seb.status_code}, + }) + fa_normal = post(fa_base, "/api/exams/submit", tok_fa, {"session_id": ids["session_fa"]}, seb=True) + go_normal = post(go_base, "/api/exams/submit", tok_go, {"session_id": ids["session_go"]}, seb=True) + report["submit"]["cases"].append(compare_status("normal", fa_normal, go_normal, 200)) + fa_rep = post(fa_base, "/api/exams/submit", tok_fa, {"session_id": ids["session_fa"]}, seb=True) + go_rep = post(go_base, "/api/exams/submit", tok_go, {"session_id": ids["session_go"]}, seb=True) + report["submit"]["cases"].append(compare_status("repeat", fa_rep, go_rep, 200)) + submit_cases = [ + ("paused", tok, {"session_id": ids["paused"]}, 400, True), + ("ownership", other_tok, {"session_id": ids["session_fa"]}, 404, True), + ("invalid_status_already", tok, {"session_id": ids["submitted"]}, 200, True), + ("missing_auth", None, {"session_id": ids["session"]}, 401, False), + ("malformed", tok, None, 422, False), + ] + for name, token, body, expect, seb in submit_cases: + if body is None: + fa_resp = post(fa_base, "/api/exams/submit", token, None, raw=b"{", seb=seb) + go_resp = post(go_base, "/api/exams/submit", token, None, raw=b"{", seb=seb) + else: + fa_resp = post(fa_base, "/api/exams/submit", token, body, seb=seb) + go_resp = post(go_base, "/api/exams/submit", token, body, seb=seb) + report["submit"]["cases"].append(compare_status(name, fa_resp, go_resp, expect)) + + async def db_submit_state() -> dict[str, Any]: + conn = await asyncpg.connect(PG, statement_cache_size=0) + try: + row = await conn.fetchrow("SELECT status, score FROM exam_sessions WHERE id=$1", ids["session_fa"]) + logs = int(await conn.fetchval("SELECT count(*) FROM exam_logs WHERE session_id=$1 AND event_type=ANY($2::text[])", ids["session_fa"], ["EXAM_SUBMITTED", "SCORE_BREAKDOWN"]) or 0) + return {"status": row["status"] if row else None, "score": float(row["score"]) if row and row["score"] is not None else None, "logs": logs} + finally: + await conn.close() + + report["submit"]["db"] = asyncio.run(db_submit_state()) + report["submit"]["db"]["ok"] = report["submit"]["db"]["status"] == "submitted" and report["submit"]["db"]["logs"] >= 2 + + def failed(section: str) -> list[dict[str, Any]]: + return [item for item in report[section]["cases"] if not item.get("ok")] + + report["autosave"]["parity_pass"] = not failed("autosave") and report["autosave"]["redis"]["ok"] and report["autosave"]["db_unchanged"]["ok"] + report["batch"]["parity_pass"] = not failed("batch") and report["batch"]["db"]["ok"] + report["submit"]["parity_pass"] = not failed("submit") and report["submit"]["db"]["ok"] + if not (report["autosave"]["parity_pass"] and report["batch"]["parity_pass"] and report["submit"]["parity_pass"]): + report["failed"] = {"autosave": failed("autosave"), "batch": failed("batch"), "submit": failed("submit")} + print(json.dumps(report, default=str)) + return 1 + + async def seed_jobs(n: int, runtime: str, kind: str) -> list[tuple[str, dict[str, Any]]]: + conn = await asyncpg.connect(PG, statement_cache_size=0) + try: + now = datetime.now(timezone.utc) + jobs: list[tuple[str, dict[str, Any]]] = [] + for i in range(n): + uid = await insert_user(conn, f"{PREFIX}_{kind}{n}_{runtime}_{i}") + sid = int( + await conn.fetchval( + "INSERT INTO exam_sessions (user_id, exam_id, status, start_time) VALUES ($1,$2,'in_progress',$3) RETURNING id", + uid, + ids["exam_id"], + now, + ) + ) + token = mint(uid, f"{PREFIX}_{kind}{n}_{runtime}_{i}") + if kind == "autosave": + jobs.append((token, {"session_id": sid, "answers": {str(ids["q_mc"]): ids["opt_ok"]}, "timestamp": now.isoformat()})) + elif kind == "batch": + jobs.append((token, {"session_id": sid, "answers": [{"question_id": ids["q_mc"], "selected_option_id": ids["opt_ok"]}]})) + else: + await conn.execute( + "INSERT INTO answers (session_id, question_id, selected_option_id, answered_at) VALUES ($1,$2,$3,$4)", + sid, + ids["q_mc"], + ids["opt_ok"], + now, + ) + jobs.append((token, {"session_id": sid})) + return jobs + finally: + await conn.close() + + paths = {"autosave": "/api/exams/auto-save", "batch": "/api/exams/auto-save-batch", "submit": "/api/exams/submit"} + for kind in ("autosave", "batch", "submit"): + for n in (50, 200, 620): + for runtime, base, proc in (("fastapi", fa_base, fa_proc), ("go", go_base, go_proc)): + jobs = asyncio.run(seed_jobs(n, runtime, kind)) + cpu0 = cpu_seconds(proc.pid) + rss0 = rss_kb(proc.pid) + result = burst(base, paths[kind], jobs, seb=(kind == "submit")) + result.update( + { + "kind": kind, + "runtime": runtime, + "cpu_per_1000": round((cpu_seconds(proc.pid) - cpu0) / max(n, 1) * 1000, 4), + "peak_rss_kb": max(rss0, rss_kb(proc.pid)), + } + ) + report["ab"].append(result) + if result["success"] != n: + print(json.dumps(report, default=str)) + return 1 + print(json.dumps(report, default=str)) + return 0 + finally: + for proc in (fa_proc, go_proc): + try: + os.killpg(proc.pid, 15) + except Exception: + proc.terminate() + fa_log.close() + go_log.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_go_answer_native.py b/tests/test_go_answer_native.py new file mode 100644 index 0000000..a59c999 --- /dev/null +++ b/tests/test_go_answer_native.py @@ -0,0 +1,26 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ANSWER = (ROOT / "go" / "internal" / "exam" / "answer_native.go").read_text(encoding="utf-8") +SQL = (ROOT / "go" / "internal" / "persistence" / "answer_native.go").read_text(encoding="utf-8") +HTTP = (ROOT / "go" / "internal" / "exam" / "http.go").read_text(encoding="utf-8") +NGINX = (ROOT / "docker" / "nginx.production.conf").read_text(encoding="utf-8") + + +def test_answer_is_native_direct_write() -> None: + assert "func (d deps) submitAnswer" in ANSWER + assert "d.proxyExamWrite" not in ANSWER + assert "WriteSingleAnswerDirect" in ANSWER + assert "pg_advisory_xact_lock" in SQL + assert "ON CONFLICT (session_id, question_id) DO UPDATE" in SQL + assert 'mux.HandleFunc("POST /api/exams/submit-answer", d.submitAnswer)' in HTTP + + +def test_answer_does_not_change_start_or_join_routing() -> None: + start = NGINX.split("location ~ ^/api/exams/[0-9]+/start$", 1)[1].split("location ", 1)[0] + assert "proxy_pass http://$start_backend;" in start + join = NGINX.split("location = /api/exams/join", 1)[1].split("location ", 1)[0] + assert "proxy_pass http://$join_backend;" in join + answer = NGINX.split("location = /api/exams/submit-answer", 1)[1].split("location ", 1)[0] + assert "proxy_pass http://$answer_backend;" in answer diff --git a/tests/test_go_exam_write_proxy.py b/tests/test_go_exam_write_proxy.py index 9447458..76f376e 100644 --- a/tests/test_go_exam_write_proxy.py +++ b/tests/test_go_exam_write_proxy.py @@ -5,9 +5,9 @@ EXAM = ROOT / "go" / "internal" / "exam" -def test_student_exam_write_handlers_proxy_instead_of_local_mutation() -> None: +def test_non_start_student_exam_write_handlers_proxy_instead_of_local_mutation() -> None: http_src = (EXAM / "http.go").read_text(encoding="utf-8") - start_src = (EXAM / "start.go").read_text(encoding="utf-8") + start_src = (EXAM / "start_native.go").read_text(encoding="utf-8") submit_src = (EXAM / "submit.go").read_text(encoding="utf-8") runtime_src = (EXAM / "runtime.go").read_text(encoding="utf-8") @@ -15,7 +15,6 @@ def test_student_exam_write_handlers_proxy_instead_of_local_mutation() -> None: for src, name in ( (http_src, "autoSave"), (http_src, "submitAnswer"), - (start_src, "startExam"), (submit_src, "submitExam"), (runtime_src, "autoSaveBatch"), (runtime_src, "journalSync"), @@ -28,6 +27,20 @@ def test_student_exam_write_handlers_proxy_instead_of_local_mutation() -> None: assert "UpsertAnswer" not in http_src assert "UpsertAnswer" not in runtime_src - assert "CreateSession" not in start_src + start_fn = start_src.split("func (d deps) startExam", 1)[1].split("\n}", 1)[0] + assert "d.proxyExamWrite(w, r)" not in start_fn assert "BeginSubmit" not in submit_src assert "LogViolation" not in runtime_src + + +def test_pgbouncer_json_and_pool_settings_are_pinned() -> None: + store = (ROOT / "go" / "internal" / "persistence" / "persistence.go").read_text( + encoding="utf-8" + ) + start_sql = (ROOT / "go" / "internal" / "persistence" / "start_native.go").read_text( + encoding="utf-8" + ) + assert "QueryExecModeSimpleProtocol" in store + assert "StatementCacheCapacity = 0" in store + assert "pgPoolMaxConns int32 = 4" in store + assert start_sql.count("string(payload)") == 2 diff --git a/tests/test_go_join_canary_routing.py b/tests/test_go_join_canary_routing.py new file mode 100644 index 0000000..01593da --- /dev/null +++ b/tests/test_go_join_canary_routing.py @@ -0,0 +1,55 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +NGINX = (ROOT / "docker" / "nginx.production.conf").read_text(encoding="utf-8") +OFF = (ROOT / "docker" / "nginx.join-canary-off.conf").read_text(encoding="utf-8") +ACTIVE = (ROOT / "docker" / "nginx.join-canary-5pct.conf").read_text(encoding="utf-8") +RUNTIME = (ROOT / "runtime_control" / "nginx.join-canary.conf").read_text(encoding="utf-8") +START_RUNTIME = (ROOT / "runtime_control" / "nginx.start-canary.conf").read_text( + encoding="utf-8" +) +START_OFF = (ROOT / "docker" / "nginx.start-canary-off.conf").read_text(encoding="utf-8") + + +def test_runtime_join_canary_defaults_off() -> None: + assert RUNTIME == OFF + assert "split_clients" not in OFF + assert "default fastapi;" in OFF + assert START_RUNTIME == START_OFF + + +def test_stage1_is_stable_five_percent_join_only() -> None: + assert 'split_clients "$http_authorization"' in ACTIVE + assert "5% go;" in ACTIVE + assert "~^/api/exams/join(\\?|$) $go_join_cohort;" in ACTIVE + assert "default fastapi;" in ACTIVE + assert "/start" not in ACTIVE + assert "/answer" not in ACTIVE + assert "/autosave" not in ACTIVE + assert "/submit" not in ACTIVE + + +def test_join_upstream_uses_shared_go_with_fastapi_backup() -> None: + assert "include /etc/nginx/join-canary.conf;" in NGINX + assert "proxy_pass http://$join_backend;" in NGINX + assert 'jr="$go_join_canary"' in NGINX + join_loc = NGINX.split("location = /api/exams/join", 1)[1].split("location ", 1)[0] + assert "proxy_pass http://$join_backend;" in join_loc + assert "go_start_backend" not in join_loc + + +def test_join_rollout_maps_are_join_only() -> None: + docker = ROOT / "docker" + for name, pct in (("10pct", "10%"), ("25pct", "25%"), ("50pct", "50%"), ("75pct", "75%")): + text = (docker / f"nginx.join-canary-{name}.conf").read_text(encoding="utf-8") + assert 'split_clients "$http_authorization"' in text + assert f"{pct} go;" in text + assert "~^/api/exams/join(\\?|$) $go_join_cohort;" in text + assert "/start" not in text + assert "/answer" not in text + assert "/submit" not in text + full = (docker / "nginx.join-canary-100pct.conf").read_text(encoding="utf-8") + assert "split_clients" not in full + assert "~^/api/exams/join(\\?|$) go;" in full + assert "default fastapi;" in full diff --git a/tests/test_go_join_native.py b/tests/test_go_join_native.py new file mode 100644 index 0000000..2c1103d --- /dev/null +++ b/tests/test_go_join_native.py @@ -0,0 +1,43 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +JOIN_NATIVE = (ROOT / "go" / "internal" / "exam" / "join_native.go").read_text(encoding="utf-8") +JOIN_SQL = (ROOT / "go" / "internal" / "persistence" / "join_native.go").read_text( + encoding="utf-8" +) +HTTP = (ROOT / "go" / "internal" / "exam" / "http.go").read_text(encoding="utf-8") +NGINX = (ROOT / "docker" / "nginx.production.conf").read_text(encoding="utf-8") +START = (ROOT / "go" / "internal" / "exam" / "start_native.go").read_text(encoding="utf-8") + + +def test_join_is_native_read_only_and_not_proxied() -> None: + assert "func (d deps) joinExam" in JOIN_NATIVE + assert "d.proxyExamWrite" not in JOIN_NATIVE + assert "d.tryFallback" not in JOIN_NATIVE + assert "Database tidak tersedia" in JOIN_NATIVE + assert "joinService" in JOIN_NATIVE + assert 'mux.HandleFunc("POST /api/exams/join", d.joinExam)' in HTTP + + +def test_join_sql_matches_fastapi_projection() -> None: + assert "FROM exams e" in JOIN_SQL + assert "LookupJoinUser" in JOIN_SQL + assert "JOIN users u ON u.id = e.creator_id" in JOIN_SQL + assert "e.access_token = $1" in JOIN_SQL + assert "is_deleted" not in JOIN_SQL + assert "SELECT COUNT(*) FROM questions WHERE exam_id = $1" in JOIN_SQL + assert "selectinload" not in JOIN_SQL + assert "INSERT" not in JOIN_SQL + assert "UPDATE" not in JOIN_SQL + assert "BEGIN" not in JOIN_SQL + assert "password_hash" not in JOIN_SQL + assert "builder_settings" not in JOIN_SQL + + +def test_join_does_not_change_start_admission() -> None: + assert "START_DB_ADMISSION_LIMIT" not in JOIN_NATIVE + assert "func (d deps) startExam" in START + assert "location = /api/exams/join" in NGINX + join_loc = NGINX.split("location = /api/exams/join", 1)[1].split("location ", 1)[0] + assert "proxy_pass http://$join_backend;" in join_loc diff --git a/tests/test_go_start_canary_routing.py b/tests/test_go_start_canary_routing.py new file mode 100644 index 0000000..5fc83a4 --- /dev/null +++ b/tests/test_go_start_canary_routing.py @@ -0,0 +1,50 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +NGINX = (ROOT / "docker" / "nginx.production.conf").read_text(encoding="utf-8") +OFF = (ROOT / "docker" / "nginx.start-canary-off.conf").read_text(encoding="utf-8") +ACTIVE = (ROOT / "docker" / "nginx.start-canary-5pct.conf").read_text(encoding="utf-8") +RUNTIME = (ROOT / "runtime_control" / "nginx.start-canary.conf").read_text( + encoding="utf-8" +) + + +def test_runtime_canary_defaults_off() -> None: + assert RUNTIME == OFF + assert "split_clients" not in OFF + assert "default fastapi;" in OFF + + +def test_stage1_is_stable_five_percent_start_only() -> None: + assert 'split_clients "$http_authorization"' in ACTIVE + assert "5% go;" in ACTIVE + assert "~^/api/exams/[0-9]+/start$ $go_start_cohort;" in ACTIVE + assert "default fastapi;" in ACTIVE + + +def test_go_upstream_has_fastapi_backup_and_route_logging() -> None: + assert "include /etc/nginx/start-canary.conf;" in NGINX + assert "server go_server:8000 resolve max_fails=1" in NGINX + assert "server api:8000 resolve backup;" in NGINX + assert 'sr="$go_start_canary"' in NGINX + assert 'ur="$upstream_http_x_siab_replica"' in NGINX + assert "location ^~ /internal/" in NGINX + + +def test_start_rollout_maps_are_start_only() -> None: + docker = ROOT / "docker" + for name, pct in (("10pct", "10%"), ("25pct", "25%"), ("50pct", "50%"), ("75pct", "75%")): + text = (docker / f"nginx.start-canary-{name}.conf").read_text(encoding="utf-8") + assert 'split_clients "$http_authorization"' in text + assert f"{pct} go;" in text + assert "~^/api/exams/[0-9]+/start$ $go_start_cohort;" in text + assert "default fastapi;" in text + assert "/join" not in text + assert "/answer" not in text + assert "/autosave" not in text + assert "/submit" not in text + full = (docker / "nginx.start-canary-100pct.conf").read_text(encoding="utf-8") + assert "split_clients" not in full + assert "~^/api/exams/[0-9]+/start$ go;" in full + assert "default fastapi;" in full diff --git a/tests/test_go_start_parity_fixture.py b/tests/test_go_start_parity_fixture.py new file mode 100644 index 0000000..862a62a --- /dev/null +++ b/tests/test_go_start_parity_fixture.py @@ -0,0 +1,216 @@ +import json +from pathlib import Path + +from app.api.exams import _build_start_question_responses, _stable_shuffle_with_seed + + +FIXTURE = json.loads( + Path("go/internal/exam/testdata/fastapi_start_parity.json").read_text(encoding="utf-8") +) + + +def _options() -> list[dict]: + return [ + { + "id": option_id, + "option_text": chr(64 + option_id), + "order_index": option_id - 1, + "option_group": "standard", + "pair_id": None, + } + for option_id in range(1, 5) + ] + + +def _question(question_id: int, order_index: int) -> dict: + return { + "id": question_id, + "question_text": f"Q{question_id}", + "stimulus": None, + "question_type": "multiple_choice", + "pgk_type": None, + "question_settings": {}, + "points": 1, + "order_index": order_index, + "image_url": None, + "video_url": None, + "audio_url": None, + "options": _options(), + } + + +def test_fastapi_shuffle_matches_shared_go_fixture() -> None: + assert _stable_shuffle_with_seed( + [1, 2, 3, 4, 5], "siab1_test_seed" + ) == FIXTURE["stable_shuffle"] + questions = _build_start_question_responses( + [_question(question_id, index) for index, question_id in enumerate((11, 12, 13, 14))], + exam_id=9, + user_id=42, + shuffle_questions=True, + shuffle_options=True, + secret_key="test-secret", + ) + assert [question.id for question in questions] == FIXTURE["question_order"] + question_11 = next(question for question in questions if question.id == 11) + assert [option.id for option in question_11.options] == FIXTURE["option_order_question_11"] + + +def test_fastapi_table_and_image_rules_match_shared_go_fixture() -> None: + table = { + "id": 21, + "question_text": "Tabel", + "stimulus": None, + "question_type": "multiple_choice_complex", + "pgk_type": "table_validation", + "question_settings": { + "allow_table_statement_shuffle": True, + "statements": ["A", "B", "C"], + }, + "points": 1, + "order_index": 0, + "image_url": None, + "video_url": None, + "audio_url": None, + "options": [], + } + image = { + "id": 22, + "question_text": "", + "stimulus": None, + "question_type": "multiple_choice", + "pgk_type": None, + "question_settings": { + "is_placeholder": True, + "placeholder_source": "image", + "allow_placeholder_shuffle": True, + }, + "points": 1, + "order_index": 1, + "image_url": "/static/q.png", + "video_url": None, + "audio_url": None, + "options": _options(), + } + questions = _build_start_question_responses( + [table, image], + exam_id=3, + user_id=7, + shuffle_questions=False, + shuffle_options=True, + secret_key="test-secret", + ) + table_settings = questions[0].question_settings or {} + assert [ + statement["original_index"] + for statement in table_settings["statements"] + ] == FIXTURE["table_statement_order"] + assert questions[1].question_text == FIXTURE["image_placeholder_text"] + assert [option.id for option in questions[1].options] == FIXTURE[ + "image_placeholder_option_order" + ] + + +def test_fastapi_start_json_keys_and_points_match_shared_go_fixture() -> None: + from app.schemas.exam import ExamStartResponse, QuestionResponse + from datetime import datetime, timezone, timedelta + + payload = _question(1, 0) + payload["options"] = [ + { + "id": 1, + "option_text": "A", + "order_index": 0, + "option_group": "standard", + "pair_id": None, + }, + { + "id": 2, + "option_text": "B", + "order_index": 1, + "option_group": "standard", + "pair_id": None, + }, + ] + questions = _build_start_question_responses( + [payload], + exam_id=7, + user_id=5, + shuffle_questions=False, + shuffle_options=False, + secret_key="app-secret", + ) + dumped = json.loads(questions[0].model_dump_json()) + assert list(dumped.keys()) == FIXTURE["question_keys"] + assert list(dumped["options"][0].keys()) == FIXTURE["option_keys"] + assert dumped["difficulty_level"] == FIXTURE["difficulty_default"] + assert dumped["points"] == FIXTURE["points"]["int_like"] + + hard = dict(payload) + hard["difficulty_level"] = "hard" + hard_built = _build_start_question_responses( + [hard], + exam_id=7, + user_id=5, + shuffle_questions=False, + shuffle_options=False, + secret_key="app-secret", + ) + assert json.loads(hard_built[0].model_dump_json())["difficulty_level"] == "hard" + + omitted = dict(payload) + omitted.pop("difficulty_level", None) + omitted_built = _build_start_question_responses( + [omitted], + exam_id=7, + user_id=5, + shuffle_questions=False, + shuffle_options=False, + secret_key="app-secret", + ) + assert json.loads(omitted_built[0].model_dump_json())["difficulty_level"] == FIXTURE[ + "difficulty_default" + ] + + point_cases = { + "int_like": 1, + "from_1_00": "1.00", + "fractional": "1.25", + "zero": None, + } + for label, raw_points in point_cases.items(): + item = dict(payload) + item["points"] = raw_points + built = _build_start_question_responses( + [item], + exam_id=7, + user_id=5, + shuffle_questions=False, + shuffle_options=False, + secret_key="app-secret", + ) + assert json.loads(built[0].model_dump_json())["points"] == FIXTURE["points"][label] + + now = datetime(2026, 8, 27, 10, 0, 0, tzinfo=timezone.utc) + response = ExamStartResponse( + session_id=99, + exam_id=7, + exam_title="Ujian", + duration_minutes=60, + question_count=1, + start_time=now, + end_time=now + timedelta(minutes=60), + server_time=now, + show_results=False, + show_teacher_name=True, + teacher_name="Guru", + subject="MTK", + exam_type="UH", + shuffle_questions=False, + shuffle_options=False, + session_poll_token="tok", + session_poll_token_expires_minutes=15, + questions=questions, + ) + assert list(json.loads(response.model_dump_json()).keys()) == FIXTURE["response_keys"] + assert isinstance(questions[0], QuestionResponse) diff --git a/tests/test_nginx_static_offload.py b/tests/test_nginx_static_offload.py index 1855825..4a9594b 100644 --- a/tests/test_nginx_static_offload.py +++ b/tests/test_nginx_static_offload.py @@ -70,14 +70,15 @@ def test_compose_defines_prometheus_exporters_on_internal_network() -> None: assert "DATA_SOURCE_NAME: postgresql://examuser:${DB_PASSWORD" in COMPOSE -def test_live_exam_writes_stay_on_python_not_go() -> None: +def test_only_exam_start_canary_can_reach_go() -> None: assert "go_server:" in COMPOSE assert 'profiles: ["native-lean"]' in COMPOSE assert "PYTHON_UPSTREAM=http://api:8000" in COMPOSE - assert "server go_server" not in NGINX_CONF - assert "go_server:8000" not in NGINX_CONF + assert "server go_server:8000" in NGINX_CONF + start = _location_block("~ ^/api/exams/[0-9]+/start$") + assert "proxy_pass http://$start_backend" in start + assert "http_500" in start for path in ( - "~ ^/api/exams/[0-9]+/start$", "= /api/exams/submit-answer", "= /api/exams/submit", "/api/", @@ -87,6 +88,14 @@ def test_live_exam_writes_stay_on_python_not_go() -> None: assert "go_server" not in block +def test_go_start_uses_scored_pgbouncer_settings_and_n4() -> None: + assert "pool_max_conns=4" in COMPOSE + assert "default_query_exec_mode=simple_protocol" in COMPOSE + assert "statement_cache_capacity=0" in COMPOSE + assert "START_DB_ADMISSION_LIMIT=4" in COMPOSE + assert "SIAB_REPLICA=go-start" in COMPOSE + + def test_memory_budget_keeps_burst_workers_and_caps_postgres() -> None: assert "shared_buffers=512MB" in COMPOSE assert "shared_buffers=2560MB" not in COMPOSE From bf50f9d701e3d88d899e1efba0ee8a062d8aafc0 Mon Sep 17 00:00:00 2001 From: SIAB1 Operations Date: Fri, 28 Aug 2026 00:03:21 +0700 Subject: [PATCH 5/8] test: complete nginx student hot-path lifecycle --- scripts/go_hotpath_lifecycle.py | 301 ++++++++++++++++++++++++++------ 1 file changed, 252 insertions(+), 49 deletions(-) diff --git a/scripts/go_hotpath_lifecycle.py b/scripts/go_hotpath_lifecycle.py index 8aa015e..2a00332 100644 --- a/scripts/go_hotpath_lifecycle.py +++ b/scripts/go_hotpath_lifecycle.py @@ -5,6 +5,8 @@ import hashlib import json import os +import threading +import time from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta, timezone from typing import Any @@ -20,6 +22,11 @@ CLASS_NAME = "XII-GO-LIFE" SEB_KEY = "life-seb" BASE = os.getenv("HOTPATH_BASE", "http://nginx").rstrip("/") +ACCESS = os.getenv("GOLIFE_TOKEN", f"L{int(time.time()) % 100000:05d}") + +_post_lock = threading.Lock() +_last_post = 0.0 +_post_gap = 0.22 def postgres_dsn(raw: str) -> str: @@ -54,6 +61,22 @@ def hdr(token: str) -> dict[str, str]: } +def pace_post() -> None: + global _last_post + with _post_lock: + wait = _post_gap - (time.time() - _last_post) + if wait > 0: + time.sleep(wait) + _last_post = time.time() + + +def pct(values: list[float], p: float) -> float: + if not values: + return 0.0 + idx = min(len(values) - 1, max(0, int(round(p / 100.0 * (len(values) - 1))))) + return round(values[idx], 3) + + async def cleanup(conn: asyncpg.Connection, rds: redis.Redis) -> None: user_ids = [int(r["id"]) for r in await conn.fetch("SELECT id FROM users WHERE username LIKE $1", f"{PREFIX}_%")] exam_ids = [int(r["id"]) for r in await conn.fetch("SELECT id FROM exams WHERE title LIKE $1", f"{PREFIX}_%")] @@ -85,56 +108,214 @@ async def cleanup(conn: asyncpg.Connection, rds: redis.Redis) -> None: await conn.execute("DELETE FROM users WHERE id = ANY($1::int[])", user_ids) -def one_lifecycle(token: str, exam_token: str, exam_id: int, qid: int, oid: int) -> dict[str, Any]: +def one_lifecycle(token: str, exam_token: str, exam_id: int, qid: int, oid: int, qid2: int, oid2: int) -> dict[str, Any]: errors: list[str] = [] client = httpx.Client(timeout=30.0) - join = client.post(f"{BASE}/api/exams/join", headers=hdr(token), json={"token": exam_token}) + + def post_retry(path: str, body: dict[str, Any]) -> httpx.Response: + last = httpx.Response(599) + for attempt in range(10): + pace_post() + last = client.post(f"{BASE}{path}", headers=hdr(token), json=body) + if last.status_code != 429: + return last + wait = 2.0 * (attempt + 1) + raw = last.headers.get("Retry-After") + if raw: + try: + wait = float(raw) + except ValueError: + pass + else: + try: + wait = float(last.json().get("retry_after") or wait) + except Exception: + pass + time.sleep(min(65.0, wait)) + return last + + join = post_retry("/api/exams/join", {"token": exam_token}) if join.status_code != 200: - return {"ok": False, "errors": [f"join {join.status_code}"], "elapsed_ms": 0} + return {"ok": False, "errors": [f"join {join.status_code}"], "elapsed_ms": 0, "session_id": 0} started = datetime.now(timezone.utc) - start = client.post(f"{BASE}/api/exams/{exam_id}/start", headers=hdr(token), json={}) + start = post_retry(f"/api/exams/{exam_id}/start", {}) if start.status_code != 200: - return {"ok": False, "errors": [f"start {start.status_code} {start.text[:120]}"], "elapsed_ms": 0} + return { + "ok": False, + "errors": [f"start {start.status_code} {start.text[:120]}"], + "elapsed_ms": 0, + "session_id": 0, + } session_id = int(start.json()["session_id"]) - ans = client.post( - f"{BASE}/api/exams/submit-answer", - headers=hdr(token), - json={"session_id": session_id, "question_id": qid, "selected_option_id": oid}, + ans = post_retry( + "/api/exams/submit-answer", + {"session_id": session_id, "question_id": qid, "selected_option_id": oid}, ) if ans.status_code != 200: errors.append(f"answer {ans.status_code}") - auto = client.post( - f"{BASE}/api/exams/auto-save", - headers=hdr(token), - json={"session_id": session_id, "answers": {str(qid): oid}, "timestamp": datetime.now(timezone.utc).isoformat()}, + auto = post_retry( + "/api/exams/auto-save", + { + "session_id": session_id, + "answers": {str(qid): oid}, + "timestamp": datetime.now(timezone.utc).isoformat(), + }, ) if auto.status_code != 200: errors.append(f"autosave {auto.status_code}") + ans2 = post_retry( + "/api/exams/submit-answer", + {"session_id": session_id, "question_id": qid2, "selected_option_id": oid2}, + ) + if ans2.status_code != 200: + errors.append(f"answer2 {ans2.status_code}") + batch = post_retry( + "/api/exams/auto-save-batch", + { + "session_id": session_id, + "answers": [ + {"question_id": qid, "selected_option_id": oid}, + {"question_id": qid2, "selected_option_id": oid2}, + ], + }, + ) + if batch.status_code != 200: + errors.append(f"batch {batch.status_code}") resume = client.get(f"{BASE}/api/exams/session/{session_id}/resume", headers=hdr(token)) if resume.status_code != 200: errors.append(f"resume {resume.status_code}") - upd = client.post( - f"{BASE}/api/exams/submit-answer", - headers=hdr(token), - json={"session_id": session_id, "question_id": qid, "selected_option_id": oid}, + upd = post_retry( + "/api/exams/submit-answer", + {"session_id": session_id, "question_id": qid, "selected_option_id": oid}, ) if upd.status_code != 200: errors.append(f"update {upd.status_code}") - sub = client.post(f"{BASE}/api/exams/submit", headers=hdr(token), json={"session_id": session_id}) + sub = post_retry("/api/exams/submit", {"session_id": session_id}) if sub.status_code != 200: errors.append(f"submit {sub.status_code} {sub.text[:120]}") + score = None + try: + score = sub.json().get("score") + except Exception: + pass elapsed = (datetime.now(timezone.utc) - started).total_seconds() * 1000 return { "ok": not errors, "errors": errors, "elapsed_ms": elapsed, "session_id": session_id, + "score": score, "join_replica": join.headers.get("x-siab-replica", ""), "start_replica": start.headers.get("x-siab-replica", ""), "answer_replica": ans.headers.get("x-siab-replica", ""), + "autosave_replica": auto.headers.get("x-siab-replica", ""), + "batch_replica": batch.headers.get("x-siab-replica", ""), + "submit_replica": sub.headers.get("x-siab-replica", ""), } +async def assert_sessions( + conn: asyncpg.Connection, + rds: redis.Redis, + session_ids: list[int], + qid: int, + qid2: int, + exam_id: int, + check_redis_sid: int | None, +) -> list[str]: + errors: list[str] = [] + if not session_ids: + return ["no sessions"] + rows = await conn.fetch( + "SELECT id, status, score FROM exam_sessions WHERE id = ANY($1::int[])", + session_ids, + ) + by_id = {int(r["id"]): r for r in rows} + for sid in session_ids: + row = by_id.get(sid) + if row is None: + errors.append(f"missing session {sid}") + continue + if row["status"] != "submitted": + errors.append(f"status {sid}={row['status']}") + score = float(row["score"] or -1) + if abs(score - 100.0) > 0.01: + errors.append(f"wrong score {sid}={score}") + answers = await conn.fetch( + """ + SELECT session_id, question_id, count(*) AS n + FROM answers WHERE session_id = ANY($1::int[]) + GROUP BY session_id, question_id + """, + session_ids, + ) + seen: dict[int, set[int]] = {} + dups = 0 + for row in answers: + sid = int(row["session_id"]) + q = int(row["question_id"]) + n = int(row["n"]) + if n > 1: + dups += 1 + seen.setdefault(sid, set()).add(q) + if dups: + errors.append(f"duplicate answers={dups}") + lost = 0 + for sid in session_ids: + got = seen.get(sid, set()) + if qid not in got or qid2 not in got: + lost += 1 + if lost: + errors.append(f"lost answers sessions={lost}") + live = int( + await conn.fetchval( + """ + SELECT count(*) FROM ( + SELECT user_id FROM exam_sessions + WHERE exam_id=$1 AND status='in_progress' + GROUP BY user_id HAVING count(*) > 1 + ) t + """, + exam_id, + ) + or 0 + ) + if live: + errors.append(f"duplicate live sessions={live}") + logs = await conn.fetch( + """ + SELECT session_id, event_type, count(*) AS n + FROM exam_logs + WHERE session_id = ANY($1::int[]) + AND event_type = ANY($2::text[]) + GROUP BY session_id, event_type + """, + session_ids, + ["SESSION_START", "EXAM_SUBMITTED", "SCORE_BREAKDOWN"], + ) + logmap: dict[int, dict[str, int]] = {} + for row in logs: + logmap.setdefault(int(row["session_id"]), {})[str(row["event_type"])] = int(row["n"]) + missing = 0 + for sid in session_ids: + got = logmap.get(sid, {}) + if got.get("SESSION_START", 0) != 1 or got.get("EXAM_SUBMITTED", 0) != 1 or got.get("SCORE_BREAKDOWN", 0) != 1: + missing += 1 + if missing: + errors.append(f"missing audit logs sessions={missing}") + if check_redis_sid: + raw = rds.get(f"exam_answers:{check_redis_sid}") + ttl = int(rds.ttl(f"exam_answers:{check_redis_sid}")) + if raw: + try: + json.loads(raw) + except Exception: + errors.append("malformed exam_answers json") + if ttl == 0: + errors.append("exam_answers ttl=0") + return errors + + async def amain() -> dict[str, Any]: database_url = os.getenv("DATABASE_URL", "") jwt_secret = os.getenv("JWT_SECRET_KEY", "") @@ -166,7 +347,7 @@ async def amain() -> dict[str, Any]: is_published, subject, exam_type, show_teacher_name, access_token, is_deleted, has_ever_had_results ) VALUES ( - $1,$2,90,$3,$4,3,false,false,true,$5,true,'MTK','UTS',true,'LIFE01',false,false + $1,$2,90,$3,$4,3,false,false,true,$5,true,'MTK','UTS',true,$6,false,false ) RETURNING id """, f"{PREFIX}_exam", @@ -174,6 +355,7 @@ async def amain() -> dict[str, Any]: now - timedelta(hours=1), now + timedelta(hours=3), SEB_KEY, + ACCESS, ) ) qid = int( @@ -191,6 +373,21 @@ async def amain() -> dict[str, Any]: qid, ) ) + qid2 = int( + await conn.fetchval( + """ + INSERT INTO questions (exam_id, question_text, question_type, difficulty_level, points, order_index, question_settings) + VALUES ($1,'MC2','multiple_choice','easy',1,1,'{}'::jsonb) RETURNING id + """, + exam_id, + ) + ) + oid2 = int( + await conn.fetchval( + "INSERT INTO question_options (question_id, option_text, is_correct, order_index) VALUES ($1,'B',true,0) RETURNING id", + qid2, + ) + ) uid = int( await conn.fetchval( """ @@ -202,49 +399,55 @@ async def amain() -> dict[str, Any]: ) ) tok = mint(uid, f"{PREFIX}_s", jwt_secret) - life = one_lifecycle(tok, "LIFE01", exam_id, qid, oid) + life = one_lifecycle(tok, ACCESS, exam_id, qid, oid, qid2, oid2) report["lifecycle"] = life if not life["ok"]: report["errors"].extend(life["errors"]) session_id = int(life.get("session_id") or 0) - answers = int(await conn.fetchval("SELECT count(*) FROM answers WHERE session_id=$1", session_id) or 0) - status = await conn.fetchval("SELECT status FROM exam_sessions WHERE id=$1", session_id) - report["session_status"] = status - report["answers"] = answers - if status != "submitted" or answers < 1: - report["errors"].append(f"final state status={status} answers={answers}") - users = [] - tokens = [] - for i in range(mixed): - name = f"{PREFIX}_m{i:03d}" - mid = int( - await conn.fetchval( - """ - INSERT INTO users (username, password_hash, full_name, role, student_class, is_active) - VALUES ($1,'x',$1,'student',$2,true) RETURNING id - """, - name, - CLASS_NAME, + life_errors = await assert_sessions(conn, rds, [session_id] if session_id else [], qid, qid2, exam_id, session_id) + report["lifecycle_db"] = "PASS" if not life_errors else life_errors + report["errors"].extend(life_errors) + mixed_rows: list[dict[str, Any]] = [] + wall0 = time.time() + if mixed > 0 and not report["errors"]: + tokens = [] + for i in range(mixed): + name = f"{PREFIX}_m{i:03d}" + mid = int( + await conn.fetchval( + """ + INSERT INTO users (username, password_hash, full_name, role, student_class, is_active) + VALUES ($1,'x',$1,'student',$2,true) RETURNING id + """, + name, + CLASS_NAME, + ) ) - ) - users.append(mid) - tokens.append(mint(mid, name, jwt_secret)) - with ThreadPoolExecutor(max_workers=mixed) as pool: - mixed_rows = list( - pool.map(lambda tok: one_lifecycle(tok, "LIFE01", exam_id, qid, oid), tokens) - ) - elapsed = sorted(item["elapsed_ms"] for item in mixed_rows if item["elapsed_ms"]) + tokens.append(mint(mid, name, jwt_secret)) + with ThreadPoolExecutor(max_workers=mixed) as pool: + mixed_rows = list( + pool.map(lambda item: one_lifecycle(item, ACCESS, exam_id, qid, oid, qid2, oid2), tokens) + ) + wall = max(0.001, time.time() - wall0) + elapsed = sorted(float(item["elapsed_ms"]) for item in mixed_rows if item.get("elapsed_ms")) success = sum(1 for item in mixed_rows if item["ok"]) + mixed_sids = [int(item["session_id"]) for item in mixed_rows if item.get("session_id")] + mixed_db = await assert_sessions(conn, rds, mixed_sids, qid, qid2, exam_id, None) if mixed_sids else [] report["mixed"] = { "n": mixed, "success": success, "correctness": round(100.0 * success / mixed, 2) if mixed else 0, - "p95": elapsed[min(len(elapsed) - 1, max(0, int(round(0.95 * (len(elapsed) - 1)))))] if elapsed else 0, - "p99": elapsed[min(len(elapsed) - 1, max(0, int(round(0.99 * (len(elapsed) - 1)))))] if elapsed else 0, + "p50": pct(elapsed, 50), + "p95": pct(elapsed, 95), + "p99": pct(elapsed, 99), + "max": round(elapsed[-1], 3) if elapsed else 0, + "throughput": round(success / wall, 3) if mixed else 0, "errors": [item["errors"] for item in mixed_rows if not item["ok"]][:5], + "db": "PASS" if not mixed_db else mixed_db, } - if success != mixed: + if mixed and success != mixed: report["errors"].append(f"mixed {success}/{mixed}") + report["errors"].extend(mixed_db) origin = httpx.get("http://nginx/health", timeout=5).status_code report["origin"] = origin await cleanup(conn, rds) From 2856e47dca8c4e6105825ebc552f31d031fd7058 Mon Sep 17 00:00:00 2001 From: SIAB1 Operations Date: Fri, 28 Aug 2026 00:14:43 +0700 Subject: [PATCH 6/8] test: assert full student hot-path lifecycle through nginx --- scripts/go_hotpath_lifecycle.py | 373 +++++++++++++++++++++++--------- 1 file changed, 266 insertions(+), 107 deletions(-) diff --git a/scripts/go_hotpath_lifecycle.py b/scripts/go_hotpath_lifecycle.py index 2a00332..64efbc3 100644 --- a/scripts/go_hotpath_lifecycle.py +++ b/scripts/go_hotpath_lifecycle.py @@ -23,10 +23,13 @@ SEB_KEY = "life-seb" BASE = os.getenv("HOTPATH_BASE", "http://nginx").rstrip("/") ACCESS = os.getenv("GOLIFE_TOKEN", f"L{int(time.time()) % 100000:05d}") +REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") _post_lock = threading.Lock() _last_post = 0.0 _post_gap = 0.22 +_count_lock = threading.Lock() +_429_count = 0 def postgres_dsn(raw: str) -> str: @@ -70,6 +73,12 @@ def pace_post() -> None: _last_post = time.time() +def note_429() -> None: + global _429_count + with _count_lock: + _429_count += 1 + + def pct(values: list[float], p: float) -> float: if not values: return 0.0 @@ -77,6 +86,28 @@ def pct(values: list[float], p: float) -> float: return round(values[idx], 3) +def is_go(replica: str) -> bool: + marker = (replica or "").lower() + return marker.startswith("go") + + +def parse_json(raw: Any) -> Any | None: + if raw is None: + return None + if isinstance(raw, (bytes, bytearray)): + raw = raw.decode() + try: + return json.loads(raw) + except Exception: + return None + + +def cache_has_question(payload: Any, question_id: int) -> bool: + if not isinstance(payload, dict): + return False + return str(question_id) in payload or question_id in payload + + async def cleanup(conn: asyncpg.Connection, rds: redis.Redis) -> None: user_ids = [int(r["id"]) for r in await conn.fetch("SELECT id FROM users WHERE username LIKE $1", f"{PREFIX}_%")] exam_ids = [int(r["id"]) for r in await conn.fetch("SELECT id FROM exams WHERE title LIKE $1", f"{PREFIX}_%")] @@ -108,17 +139,91 @@ async def cleanup(conn: asyncpg.Connection, rds: redis.Redis) -> None: await conn.execute("DELETE FROM users WHERE id = ANY($1::int[])", user_ids) -def one_lifecycle(token: str, exam_token: str, exam_id: int, qid: int, oid: int, qid2: int, oid2: int) -> dict[str, Any]: +def redis_before(rds: redis.Redis, session_id: int, qid: int, qid2: int) -> list[str]: errors: list[str] = [] + answers_raw = rds.get(f"exam_answers:{session_id}") + answers_ttl = int(rds.ttl(f"exam_answers:{session_id}")) + payload = parse_json(answers_raw) + if payload is None: + errors.append("pre-submit exam_answers missing/invalid json") + else: + if not cache_has_question(payload, qid) or not cache_has_question(payload, qid2): + errors.append("pre-submit exam_answers missing Q1/Q2") + if answers_ttl <= 0: + errors.append(f"pre-submit exam_answers ttl={answers_ttl}") + session_raw = rds.get(f"exam_session:{session_id}") + session_ttl = int(rds.ttl(f"exam_session:{session_id}")) + session_payload = parse_json(session_raw) + if session_payload is None: + errors.append("pre-submit exam_session missing/invalid json") + elif session_ttl <= 0: + errors.append(f"pre-submit exam_session ttl={session_ttl}") + members = {str(item) for item in rds.smembers(f"exam_answered_questions:{session_id}")} + if str(qid) not in members or str(qid2) not in members: + errors.append("pre-submit exam_answered_questions missing Q1/Q2") + aq_ttl = int(rds.ttl(f"exam_answered_questions:{session_id}")) + if aq_ttl <= 0: + errors.append(f"pre-submit exam_answered_questions ttl={aq_ttl}") + return errors + + +def redis_after(rds: redis.Redis, session_id: int) -> list[str]: + errors: list[str] = [] + session_raw = rds.get(f"exam_session:{session_id}") + session_ttl = int(rds.ttl(f"exam_session:{session_id}")) + payload = parse_json(session_raw) + if payload is None: + errors.append("post-submit exam_session missing/invalid json") + return errors + if str(payload.get("status") or "").lower() != "submitted": + errors.append(f"post-submit exam_session status={payload.get('status')}") + if "end_time" not in payload: + errors.append("post-submit exam_session missing end_time") + if session_ttl <= 0: + errors.append(f"post-submit exam_session ttl={session_ttl}") + answers_raw = rds.get(f"exam_answers:{session_id}") + if answers_raw is not None: + if parse_json(answers_raw) is None: + errors.append("post-submit exam_answers invalid json") + answers_ttl = int(rds.ttl(f"exam_answers:{session_id}")) + if answers_ttl <= 0: + errors.append(f"post-submit exam_answers ttl={answers_ttl}") + return errors + + +def one_lifecycle( + token: str, + exam_token: str, + exam_id: int, + qid: int, + oid: int, + oid_wrong: int, + qid2: int, + oid2: int, +) -> dict[str, Any]: + errors: list[str] = [] + service_ms = 0.0 client = httpx.Client(timeout=30.0) + rds = redis.Redis.from_url(REDIS_URL, decode_responses=True) + + def timed_get(path: str) -> httpx.Response: + nonlocal service_ms + t0 = time.perf_counter() + response = client.get(f"{BASE}{path}", headers=hdr(token)) + service_ms += (time.perf_counter() - t0) * 1000 + return response def post_retry(path: str, body: dict[str, Any]) -> httpx.Response: + nonlocal service_ms last = httpx.Response(599) for attempt in range(10): pace_post() + t0 = time.perf_counter() last = client.post(f"{BASE}{path}", headers=hdr(token), json=body) + service_ms += (time.perf_counter() - t0) * 1000 if last.status_code != 429: return last + note_429() wait = 2.0 * (attempt + 1) raw = last.headers.get("Retry-After") if raw: @@ -134,22 +239,29 @@ def post_retry(path: str, body: dict[str, Any]) -> httpx.Response: time.sleep(min(65.0, wait)) return last + wall0 = time.perf_counter() join = post_retry("/api/exams/join", {"token": exam_token}) if join.status_code != 200: - return {"ok": False, "errors": [f"join {join.status_code}"], "elapsed_ms": 0, "session_id": 0} - started = datetime.now(timezone.utc) + return { + "ok": False, + "errors": [f"join {join.status_code}"], + "service_ms": round(service_ms, 3), + "wall_ms": round((time.perf_counter() - wall0) * 1000, 3), + "session_id": 0, + } start = post_retry(f"/api/exams/{exam_id}/start", {}) if start.status_code != 200: return { "ok": False, "errors": [f"start {start.status_code} {start.text[:120]}"], - "elapsed_ms": 0, + "service_ms": round(service_ms, 3), + "wall_ms": round((time.perf_counter() - wall0) * 1000, 3), "session_id": 0, } session_id = int(start.json()["session_id"]) ans = post_retry( "/api/exams/submit-answer", - {"session_id": session_id, "question_id": qid, "selected_option_id": oid}, + {"session_id": session_id, "question_id": qid, "selected_option_id": oid_wrong}, ) if ans.status_code != 200: errors.append(f"answer {ans.status_code}") @@ -157,7 +269,7 @@ def post_retry(path: str, body: dict[str, Any]) -> httpx.Response: "/api/exams/auto-save", { "session_id": session_id, - "answers": {str(qid): oid}, + "answers": {str(qid): oid_wrong}, "timestamp": datetime.now(timezone.utc).isoformat(), }, ) @@ -174,14 +286,15 @@ def post_retry(path: str, body: dict[str, Any]) -> httpx.Response: { "session_id": session_id, "answers": [ - {"question_id": qid, "selected_option_id": oid}, + {"question_id": qid, "selected_option_id": oid_wrong}, {"question_id": qid2, "selected_option_id": oid2}, ], }, ) if batch.status_code != 200: errors.append(f"batch {batch.status_code}") - resume = client.get(f"{BASE}/api/exams/session/{session_id}/resume", headers=hdr(token)) + errors.extend(redis_before(rds, session_id, qid, qid2)) + resume = timed_get(f"/api/exams/session/{session_id}/resume") if resume.status_code != 200: errors.append(f"resume {resume.status_code}") upd = post_retry( @@ -193,39 +306,64 @@ def post_retry(path: str, body: dict[str, Any]) -> httpx.Response: sub = post_retry("/api/exams/submit", {"session_id": session_id}) if sub.status_code != 200: errors.append(f"submit {sub.status_code} {sub.text[:120]}") + errors.extend(redis_after(rds, session_id)) score = None try: score = sub.json().get("score") except Exception: pass - elapsed = (datetime.now(timezone.utc) - started).total_seconds() * 1000 + replicas = { + "join": join.headers.get("x-siab-replica", ""), + "start": start.headers.get("x-siab-replica", ""), + "answer": ans.headers.get("x-siab-replica", ""), + "autosave": auto.headers.get("x-siab-replica", ""), + "answer2": ans2.headers.get("x-siab-replica", ""), + "batch": batch.headers.get("x-siab-replica", ""), + "update": upd.headers.get("x-siab-replica", ""), + "submit": sub.headers.get("x-siab-replica", ""), + } + if any(not is_go(value) for value in replicas.values()): + errors.append(f"non-go replica {replicas}") return { "ok": not errors, "errors": errors, - "elapsed_ms": elapsed, + "service_ms": round(service_ms, 3), + "wall_ms": round((time.perf_counter() - wall0) * 1000, 3), "session_id": session_id, "score": score, - "join_replica": join.headers.get("x-siab-replica", ""), - "start_replica": start.headers.get("x-siab-replica", ""), - "answer_replica": ans.headers.get("x-siab-replica", ""), - "autosave_replica": auto.headers.get("x-siab-replica", ""), - "batch_replica": batch.headers.get("x-siab-replica", ""), - "submit_replica": sub.headers.get("x-siab-replica", ""), + "replicas": replicas, + "join_replica": replicas["join"], + "start_replica": replicas["start"], + "answer_replica": replicas["answer"], + "autosave_replica": replicas["autosave"], + "answer2_replica": replicas["answer2"], + "batch_replica": replicas["batch"], + "update_replica": replicas["update"], + "submit_replica": replicas["submit"], } async def assert_sessions( conn: asyncpg.Connection, - rds: redis.Redis, session_ids: list[int], qid: int, qid2: int, + oid: int, + oid2: int, exam_id: int, - check_redis_sid: int | None, -) -> list[str]: - errors: list[str] = [] +) -> dict[str, Any]: + out = { + "errors": [], + "lost": 0, + "dups": 0, + "live": 0, + "wrong_scores": 0, + "missing_audit": 0, + "stale_update": 0, + } if not session_ids: - return ["no sessions"] + out["errors"].append("no sessions") + return out rows = await conn.fetch( "SELECT id, status, score FROM exam_sessions WHERE id = ANY($1::int[])", session_ids, @@ -234,40 +372,39 @@ async def assert_sessions( for sid in session_ids: row = by_id.get(sid) if row is None: - errors.append(f"missing session {sid}") + out["errors"].append(f"missing session {sid}") continue if row["status"] != "submitted": - errors.append(f"status {sid}={row['status']}") + out["errors"].append(f"status {sid}={row['status']}") score = float(row["score"] or -1) if abs(score - 100.0) > 0.01: - errors.append(f"wrong score {sid}={score}") + out["wrong_scores"] += 1 answers = await conn.fetch( """ - SELECT session_id, question_id, count(*) AS n + SELECT session_id, question_id, selected_option_id, count(*) AS n FROM answers WHERE session_id = ANY($1::int[]) - GROUP BY session_id, question_id + GROUP BY session_id, question_id, selected_option_id """, session_ids, ) - seen: dict[int, set[int]] = {} - dups = 0 + seen: dict[int, dict[int, int]] = {} + pair_counts: dict[tuple[int, int], int] = {} for row in answers: sid = int(row["session_id"]) q = int(row["question_id"]) + opt = int(row["selected_option_id"] or 0) n = int(row["n"]) - if n > 1: - dups += 1 - seen.setdefault(sid, set()).add(q) - if dups: - errors.append(f"duplicate answers={dups}") - lost = 0 + pair_counts[(sid, q)] = pair_counts.get((sid, q), 0) + n + seen.setdefault(sid, {})[q] = opt + out["dups"] = sum(1 for count in pair_counts.values() if count > 1) for sid in session_ids: - got = seen.get(sid, set()) + got = seen.get(sid, {}) if qid not in got or qid2 not in got: - lost += 1 - if lost: - errors.append(f"lost answers sessions={lost}") - live = int( + out["lost"] += 1 + continue + if got[qid] != oid or got[qid2] != oid2: + out["stale_update"] += 1 + out["live"] = int( await conn.fetchval( """ SELECT count(*) FROM ( @@ -280,8 +417,6 @@ async def assert_sessions( ) or 0 ) - if live: - errors.append(f"duplicate live sessions={live}") logs = await conn.fetch( """ SELECT session_id, event_type, count(*) AS n @@ -296,36 +431,40 @@ async def assert_sessions( logmap: dict[int, dict[str, int]] = {} for row in logs: logmap.setdefault(int(row["session_id"]), {})[str(row["event_type"])] = int(row["n"]) - missing = 0 for sid in session_ids: got = logmap.get(sid, {}) - if got.get("SESSION_START", 0) != 1 or got.get("EXAM_SUBMITTED", 0) != 1 or got.get("SCORE_BREAKDOWN", 0) != 1: - missing += 1 - if missing: - errors.append(f"missing audit logs sessions={missing}") - if check_redis_sid: - raw = rds.get(f"exam_answers:{check_redis_sid}") - ttl = int(rds.ttl(f"exam_answers:{check_redis_sid}")) - if raw: - try: - json.loads(raw) - except Exception: - errors.append("malformed exam_answers json") - if ttl == 0: - errors.append("exam_answers ttl=0") - return errors + if got.get("SESSION_START", 0) < 1 or got.get("EXAM_SUBMITTED", 0) < 1 or got.get("SCORE_BREAKDOWN", 0) < 1: + out["missing_audit"] += 1 + if out["wrong_scores"]: + out["errors"].append(f"wrong scores={out['wrong_scores']}") + if out["dups"]: + out["errors"].append(f"duplicate answers={out['dups']}") + if out["lost"]: + out["errors"].append(f"lost answers sessions={out['lost']}") + if out["stale_update"]: + out["errors"].append(f"stale update sessions={out['stale_update']}") + if out["live"]: + out["errors"].append(f"duplicate live sessions={out['live']}") + if out["missing_audit"]: + out["errors"].append(f"missing audit logs sessions={out['missing_audit']}") + return out + + +async def leftover_count(conn: asyncpg.Connection) -> int: + users = int(await conn.fetchval("SELECT count(*) FROM users WHERE username LIKE $1", f"{PREFIX}_%") or 0) + exams = int(await conn.fetchval("SELECT count(*) FROM exams WHERE title LIKE $1", f"{PREFIX}_%") or 0) + return users + exams async def amain() -> dict[str, Any]: database_url = os.getenv("DATABASE_URL", "") jwt_secret = os.getenv("JWT_SECRET_KEY", "") - redis_url = os.getenv("REDIS_URL", "redis://redis:6379/0") - mixed = int(os.getenv("HOTPATH_MIXED", "50")) + mixed = int(os.getenv("HOTPATH_MIXED", "0")) if not database_url or not jwt_secret: raise SystemExit("DATABASE_URL and JWT_SECRET_KEY are required") - rds = redis.Redis.from_url(redis_url, decode_responses=True) + rds = redis.Redis.from_url(REDIS_URL, decode_responses=True) conn = await asyncpg.connect(postgres_dsn(database_url), statement_cache_size=0) - report: dict[str, Any] = {"errors": []} + report: dict[str, Any] = {"errors": [], "redis": "PASS", "429": 0} try: await cleanup(conn, rds) now = datetime.now(timezone.utc) @@ -373,6 +512,12 @@ async def amain() -> dict[str, Any]: qid, ) ) + oid_wrong = int( + await conn.fetchval( + "INSERT INTO question_options (question_id, option_text, is_correct, order_index) VALUES ($1,'C',false,1) RETURNING id", + qid, + ) + ) qid2 = int( await conn.fetchval( """ @@ -388,28 +533,29 @@ async def amain() -> dict[str, Any]: qid2, ) ) - uid = int( - await conn.fetchval( - """ - INSERT INTO users (username, password_hash, full_name, role, student_class, is_active) - VALUES ($1,'x',$1,'student',$2,true) RETURNING id - """, - f"{PREFIX}_s", - CLASS_NAME, + args = (ACCESS, exam_id, qid, oid, oid_wrong, qid2, oid2) + if mixed <= 0: + uid = int( + await conn.fetchval( + """ + INSERT INTO users (username, password_hash, full_name, role, student_class, is_active) + VALUES ($1,'x',$1,'student',$2,true) RETURNING id + """, + f"{PREFIX}_s", + CLASS_NAME, + ) ) - ) - tok = mint(uid, f"{PREFIX}_s", jwt_secret) - life = one_lifecycle(tok, ACCESS, exam_id, qid, oid, qid2, oid2) - report["lifecycle"] = life - if not life["ok"]: - report["errors"].extend(life["errors"]) - session_id = int(life.get("session_id") or 0) - life_errors = await assert_sessions(conn, rds, [session_id] if session_id else [], qid, qid2, exam_id, session_id) - report["lifecycle_db"] = "PASS" if not life_errors else life_errors - report["errors"].extend(life_errors) - mixed_rows: list[dict[str, Any]] = [] - wall0 = time.time() - if mixed > 0 and not report["errors"]: + life = one_lifecycle(mint(uid, f"{PREFIX}_s", jwt_secret), *args) + report["lifecycle"] = life + if not life["ok"]: + report["errors"].extend(life["errors"]) + session_id = int(life.get("session_id") or 0) + db = await assert_sessions(conn, [session_id] if session_id else [], qid, qid2, oid, oid2, exam_id) + report["lifecycle_db"] = "PASS" if not db["errors"] else db + report["errors"].extend(db["errors"]) + if any("exam_answers" in err or "exam_session" in err or "exam_answered" in err for err in life["errors"]): + report["redis"] = "FAIL" + else: tokens = [] for i in range(mixed): name = f"{PREFIX}_m{i:03d}" @@ -424,39 +570,52 @@ async def amain() -> dict[str, Any]: ) ) tokens.append(mint(mid, name, jwt_secret)) - with ThreadPoolExecutor(max_workers=mixed) as pool: - mixed_rows = list( - pool.map(lambda item: one_lifecycle(item, ACCESS, exam_id, qid, oid, qid2, oid2), tokens) - ) - wall = max(0.001, time.time() - wall0) - elapsed = sorted(float(item["elapsed_ms"]) for item in mixed_rows if item.get("elapsed_ms")) - success = sum(1 for item in mixed_rows if item["ok"]) - mixed_sids = [int(item["session_id"]) for item in mixed_rows if item.get("session_id")] - mixed_db = await assert_sessions(conn, rds, mixed_sids, qid, qid2, exam_id, None) if mixed_sids else [] - report["mixed"] = { - "n": mixed, - "success": success, - "correctness": round(100.0 * success / mixed, 2) if mixed else 0, - "p50": pct(elapsed, 50), - "p95": pct(elapsed, 95), - "p99": pct(elapsed, 99), - "max": round(elapsed[-1], 3) if elapsed else 0, - "throughput": round(success / wall, 3) if mixed else 0, - "errors": [item["errors"] for item in mixed_rows if not item["ok"]][:5], - "db": "PASS" if not mixed_db else mixed_db, - } - if mixed and success != mixed: - report["errors"].append(f"mixed {success}/{mixed}") - report["errors"].extend(mixed_db) + wall0 = time.time() + workers = max(1, min(4, mixed)) + with ThreadPoolExecutor(max_workers=workers) as pool: + mixed_rows = list(pool.map(lambda item: one_lifecycle(item, *args), tokens)) + wall = max(0.001, time.time() - wall0) + service = sorted(float(item["service_ms"]) for item in mixed_rows if item.get("service_ms") is not None) + success = sum(1 for item in mixed_rows if item["ok"]) + mixed_sids = [int(item["session_id"]) for item in mixed_rows if item.get("session_id")] + db = await assert_sessions(conn, mixed_sids, qid, qid2, oid, oid2, exam_id) + redis_fail = sum( + 1 + for item in mixed_rows + if any("exam_answers" in err or "exam_session" in err or "exam_answered" in err for err in item["errors"]) + ) + if redis_fail: + report["redis"] = "FAIL" + report["mixed"] = { + "n": mixed, + "success": success, + "correctness": round(100.0 * success / mixed, 2) if mixed else 0, + "p50": pct(service, 50), + "p95": pct(service, 95), + "p99": pct(service, 99), + "max": round(service[-1], 3) if service else 0, + "wall_s": round(wall, 3), + "errors": [item["errors"] for item in mixed_rows if not item["ok"]][:5], + "db": "PASS" if not db["errors"] else db, + "lost": db["lost"], + "dups": db["dups"], + "live": db["live"], + "wrong_scores": db["wrong_scores"], + "missing_audit": db["missing_audit"], + } + if success != mixed: + report["errors"].append(f"mixed {success}/{mixed}") + report["errors"].extend(db["errors"]) origin = httpx.get("http://nginx/health", timeout=5).status_code report["origin"] = origin await cleanup(conn, rds) - leftover = int(await conn.fetchval("SELECT count(*) FROM users WHERE username LIKE $1", f"{PREFIX}_%") or 0) + leftover = await leftover_count(conn) report["cleanup"] = "PASS" if leftover == 0 else "FAIL" if leftover: report["errors"].append(f"leftovers={leftover}") finally: await conn.close() + report["429"] = _429_count report["verdict"] = "PASS" if not report["errors"] else "FAIL" print(json.dumps(report, default=str)) return report From 42746366ddba0c45d7908bda2a2e326727d33fc3 Mon Sep 17 00:00:00 2001 From: SIAB1 Operations Date: Fri, 28 Aug 2026 00:43:31 +0700 Subject: [PATCH 7/8] docs: record Go student hot-path as production primary --- .pi/HANDOFF.md | 73 ++++++++++--------------------------- AGENTS.md | 25 ++++++++----- ARCHITECTURE.md | 95 +++++++++++++++++++++++-------------------------- README.md | 30 +++++++++------- docs/HISTORY.md | 3 +- 5 files changed, 99 insertions(+), 127 deletions(-) diff --git a/.pi/HANDOFF.md b/.pi/HANDOFF.md index b55e1e9..f6efede 100644 --- a/.pi/HANDOFF.md +++ b/.pi/HANDOFF.md @@ -6,72 +6,35 @@ This is the repository's only active session checkpoint. `AGENTS.md`, source cod ## Current Objective -Complete production readiness for the deployed SIAB1 stack at `siab.man1rokanhulu.cloud` from the canonical repository at `https://github.com/kuker24/SIAB1`. +None. Student hot-path closeout is complete. Do not reopen Go routing, START/JOIN/ANSWER handlers, or live canary files without explicit ops intent. ## Current State -- Project identity is `SIAB1` / `Sistem Informasi Asesmen Berintegritas`. -- Technical slug, Compose project, database, images, and monitoring labels use `siab1`. -- Android native package is `id.siab1.kiosk`; Flutter fallback is `id.siab1.flutter`. -- Release clients require an explicit server URL; `siab1.invalid` is a non-release placeholder. -- Public hostname is `siab.man1rokanhulu.cloud`; Cloudflare remains authoritative DNS in DNS-only mode. -- SafeLine CE terminates public TLS and forwards to the loopback-only SIAB1 Nginx origin at `127.0.0.1:8080`. -- SafeLine management binds to `127.0.0.1:9443` and is accessed only through an SSH tunnel. -- SIAB1 and SafeLine are deployed at `/opt/siab1` and `/opt/safeline`; DNS cutover and public TLS are active. -- The deployed SIAB1 and SafeLine manifests and critical backend files match the canonical local sources by checksum. -- Native Android `2.0.1` build 3 is present on the VPS and its checksum is valid. -- Native Android `2.0.2+4` has been rebuilt with the original release key after a post-submit - exit-to-home fix and reinstalled on the Xiaomi `2306EPN60G`; it has not been published. -- Python/FastAPI and Flutter remain supported fallbacks. -- Legacy phase reports, stale deployment scripts, duplicate client sources, and unused web assets were removed after consolidation. +- Repo: `/home/fahmiagent/Downloads/LAB GITHUB/LAB_Transformation/SIAB1/SIAB1`. +- Branch `provenance/migration-reconciliation`. Closeout commit `2856e47dca8c4e6105825ebc552f31d031fd7058` (`scripts/go_hotpath_lifecycle.py` only). +- Production `/opt/siab1`: Go image `siab1-go:373c131` healthy. Six student routes GO 100% (join, start, submit-answer, auto-save, auto-save-batch, submit). FastAPI fallback remains in nginx maps and `go_start_backend` backup. +- VPS rerun of `2856e47` (2026-08-28): lifecycle PASS; mixed-50 50/50 100%; 5xx=0; 429=0; lost/dups/live/wrong/missing=0; Redis PASS; PgBouncer `cl_waiting=0` `maxwait=0`; origin/public `/health` 200/200. +- Codebase Memory project: `SIAB1-clean`. +- Leave dirty: `.scratch/`, `scripts/go_remaining_stage0.py`, `scripts/build_card_users_csv.py`, `scripts/sync_school_users.py`, their tests, `docker/Dockerfile.go-test`. ## Verification Evidence -- **PASS** - full Python suite: 528 tests. -- **PASS** - `python scripts/check_security.py` and release gate with `SKIP_HTTP=1`. -- **PASS** - Go test, vet, and build. -- **PASS** - Android kiosk Kotlin compile and lint. -- **PASS** - Compose config, shell syntax, and shellcheck. -- **PASS** - SIAB1 identity guard and local documentation-link guard. -- **PASS** - GitHub production hardening workflow through readiness commit `d00062e`. -- **PASS** - read-only VPS audit: 16 vCPU, 15 GiB RAM, 4 GiB unused swap, healthy SIAB1 containers, healthy ops summary, and 100% Redis stability. -- **PASS** - DNS, Let's Encrypt TLS, origin health, and public health; both health paths returned HTTP 200. -- **PASS** - automated daily backup and weekly non-destructive restore drill. -- **PASS** - weekly stateless auto-restart schedule, host-control path, and dry-run safety guard. -- **PASS** - controlled public load phases 50, 200, and 620 with 100% start/answer/submit. -- **PASS** - public violation/WebSocket smoke and upload smoke with synthetic cleanup. -- **PASS** - read-only capacity snapshot under root without weakening secret permissions. -- **PASS** - Flutter analyze and widget test using an isolated stable SDK. -- **PASS** - deployed runtime checksum dry-run and full release manifest verification. -- **POLICY BLOCKED** - export success-path while peak mode disables heavy exports; HTTP 503 guard verified. -- **PASS** - signed Android `2.0.2+4`; package, version, production URL, APK alignment, and - signer continuity with `2.0.1` were verified. SHA-256 - `44030edda5aad3622ff813a8b4b75657d4691117690963a975542f21e1685a0b`. -- **PASS** - native post-submit contract: `examSubmitted` now stops the WebView, clears auth, - and calls `finishAndRemoveTask()` so `/student/` login never appears. Physical submit retest - of this rebuilt APK is still pending. -- **PASS** - burst-latency apply 2026-08-26 (no `down -v`, zero live sessions): Nginx serves - `/static/` from disk (critical JS still `no-store`); `start_exam_session` delegates to - `_build_start_question_responses`; Prometheus exporters postgres/redis/nginx/node `up`; - Celery `result_expires=3600` and `task_ignore_result=True`; student API `--workers 2`. - Origin and public `/health` HTTP 200. Rollback copies at `/opt/siab1/*.bak-burst-20260826`. -- **PASS** - physical-device Android `2.0.2+4` smoke: clean install, cold launch, invalid and - valid login/token flows, trusted native exam start, two-answer autosave, offline/reconnect, - final submit with score, screen pinning, screenshot blocking, clean kiosk exit, and no - crash/ANR/runtime error. The isolated synthetic exam/user/session and local credentials were - removed after verification; the pre-smoke backup remains at - `/opt/siab1/backups/pre-physical-smoke-20260826T052900.sql.gz` with its checksum sidecar. +- **PASS** - single lifecycle through nginx (`HOTPATH_MIXED=0`): all replicas `go-start`, score 100, redis PASS, cleanup PASS. +- **PASS** - mixed-50 paced closeout (`HOTPATH_MIXED=50`): correctness 100%; service p50 310.018 / p95 402.694 / p99 524.476 / max 524.476; wall 87.996s. +- **PASS** - canary files 100pct for all six routes; FastAPI fallback AVAILABLE; rollback not invoked. +- Docs refreshed to match this topology: `AGENTS.md`, `ARCHITECTURE.md`, `README.md`, `docs/HISTORY.md`. ## Remaining Decisions -- Back up the recovered release signing material to approved external private storage. -- Clarify whether trusted native sessions should populate `ExamSession.is_secure_app_verified`; - native header enforcement passed, but this currently unused observability field remains false. -- Publish signed Android `2.0.2+4` only after explicit release approval. -- Test export success-path only during an approved maintenance window with peak mode disabled. -- Refresh the finite weekly auto-restart entries before the current schedule horizon expires. +- Android `2.0.2+4` still needs signing material and physical-device smoke. Never ship `siab1.invalid`. +- Synchronized-burst p95 stays a watch item on real exam waves. +- Heavy export success-path only in an approved maintenance window. +- Repo Compose still marks `go_server` as profile `native-lean`; live routing is the canary maps, not that comment. +- Do not commit the leftover dirty files unless an operator asks. ## Production Safety Boundary - Do not read or expose environment files, keys, tokens, certificates, participant answers, or credentials. - Do not deploy, publish assessments, restart services, migrate data, or run heavy tests without explicit approval and a verified backup. +- Do not use `docker compose ... down -v` on production. +- Do not overwrite `runtime_control/nginx.*-canary.conf`. Dual-write answers is forbidden. Rollback is a per-route canary swap to FastAPI. diff --git a/AGENTS.md b/AGENTS.md index 42272d4..1910577 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,29 +12,36 @@ SIAB1 (Sistem Informasi Asesmen Berintegritas) provides protected assessment del - Context contract: this file is the durable project map; `.pi/HANDOFF.md` is the only active session checkpoint. Treat every other AI handoff, save, snapshot, or progress note as historical unless that checkpoint explicitly promotes it. ## Technology -- Backend: Python 3.11, FastAPI `0.135.1`, async SQLAlchemy `2.0.48`, PostgreSQL, Redis, Celery `5.6.2`, and Nginx. +- Control and non-hot-path API: Python 3.11, FastAPI `0.135.1`, async SQLAlchemy `2.0.48`. +- Student hot-path: Go `go_server` (image `siab1-go:373c131` at last VPS closeout). FastAPI stays nginx map default and `go_start_backend` backup. +- Shared: PostgreSQL, Redis, Celery `5.6.2`, Nginx, PgBouncer. - Production images: PostgreSQL `15-alpine`, Redis `7-alpine`, Prometheus `v2.53.0`, Grafana `11.1.0`. - Client: Flutter at `flutter_client_code`; entry `flutter_client_code/lib/main.dart`; package version `2.0.0+2`; Dart SDK `>=3.0.0 <4.0.0`. ## Entry Points -- API application: `app/main.py`. +- FastAPI application: `app/main.py`. +- Go hot-path server: `go/cmd/server/main.go`. - Configuration: `app/config.py`; settings read from `.env`. Never read or print environment secrets. - Database wiring: `app/database.py`. -- High-impact API path: `app/api/exams.py`. +- High-impact FastAPI path: `app/api/exams.py` (fallback + non-hot-path). +- Live nginx canary maps: `runtime_control/nginx.{start,join,answer,autosave,batch,submit}-canary.conf`. - Production orchestration: `docker-compose.production.yml`. - Flutter application: `flutter_client_code/lib/main.dart`. +- Topology: `ARCHITECTURE.md`. Session checkpoint: `.pi/HANDOFF.md`. ## Repository Structure -- `app/api`: API routes. +- `app/api`: FastAPI routes (control plane, login, poll, export, hot-path fallback). - `app/core`: shared runtime, policy, and operational logic. - `app/middleware`: HTTP security, SXB enforcement, logging, rate limiting, and performance middleware. - `app/models`: SQLAlchemy ORM models. - `app/schemas`: Pydantic request and response schemas. - `app/services`: application services. - `app/tasks`: Celery tasks and scheduler. +- `go`: native student hot-path (join, start, submit-answer, auto-save, auto-save-batch, submit). +- `runtime_control`: live nginx canary maps; treat as production routing. - `flutter_client_code`: Flutter student client. - `docker`: production Dockerfiles, Nginx config, certificates mount path, and database initialization. -- `scripts`: maintenance, security, release-gate, and VPS-readiness commands. +- `scripts`: maintenance, security, release-gate, VPS-readiness, and hot-path closeout (`scripts/go_hotpath_lifecycle.py`). - `monitoring`: Prometheus and Grafana configuration. - `tests`: committed pytest suite; 63 files at mapping snapshot. - `docs`: operational, deployment, validation, and historical documentation. @@ -64,6 +71,7 @@ SIAB1 (Sistem Informasi Asesmen Berintegritas) provides protected assessment del - Production origin serves `/static/` from Nginx disk (`alias`); critical exam JS stays `no-store`. Prometheus exporters (postgres, redis, nginx, node) must stay `up`. - Postgres production budget is `shared_buffers=512MB` / 1536M limit. Do not restore `2560MB` without a measured working-set need. Student API `--workers 2` stays for burst. - `DEBUG=true` and `DISABLE_RATE_LIMIT=true` are development-only. Telegram alerts require configured `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_IDS`. +- Student hot-path writer is Go at 100% canary for join, start, submit-answer, auto-save, auto-save-batch, and submit. FastAPI remains the map default and `server api:8000 resolve backup`. One writer per session; dual-write answers is forbidden. Rollback is a per-route canary swap to FastAPI, not `docker compose down -v`. Do not overwrite live `runtime_control/nginx.*-canary.conf` or change START/JOIN/ANSWER Go handlers without explicit ops intent. ## Common Commands ### Local API @@ -118,13 +126,14 @@ bash scripts/verify_stable_release_vps.sh ``` ## VPS Deployment Map -The production stack is deployed on the target VPS. The facts below were verified read-only on 2026-08-22 and remain a documented snapshot rather than continuous monitoring evidence. +The production stack is deployed on the target VPS. Topology below matches the 2026-08-28 student hot-path closeout; treat host sizing and health as a snapshot, not continuous monitoring evidence. - SIAB1 is deployed at `/opt/siab1`; SafeLine is deployed at `/opt/safeline`. - Compose project, database, monitoring cluster, and image names use the `siab1` slug. -- Traffic contract: domain -> SafeLine -> loopback-only Nginx -> student or admin/control API lanes -> PgBouncer -> PostgreSQL. Service health path: `/health`. -- Nginx fronts eight student lanes (`api` through `api8`) and two isolated admin/control lanes (`api_admin`, `api_admin2`). +- Traffic contract: domain -> SafeLine -> loopback-only Nginx. Six student hot-path routes go to `go_start_backend` (Go primary, FastAPI backup). Remaining student and admin/control traffic stays on FastAPI lanes. Then PgBouncer -> PostgreSQL. Service health path: `/health`. +- Nginx fronts eight student FastAPI lanes (`api` through `api8`), two isolated admin/control lanes (`api_admin`, `api_admin2`), and `go_server` for the hot-path. - Supporting services: PostgreSQL, PgBouncer, Redis, Celery worker, Celery beat, Prometheus, Grafana, and postgres/redis/nginx/node exporters. Optional `db_replica` is Compose profile `scaling`. +- Repo Compose still lists `go_server` under profile `native-lean`; live production runs `siab1-go:373c131`. Do not take the profile comment as current routing. - Public hostname is `siab.man1rokanhulu.cloud`. Cloudflare provides authoritative DNS in DNS-only mode; SafeLine terminates public TLS and forwards to `127.0.0.1:8080`. - SafeLine management binds to `127.0.0.1:9443` and must be accessed through an SSH tunnel. Never expose the management port publicly. - The verified host has 16 vCPU, 15 GiB RAM, 4 GiB swap, and a 58 GiB root filesystem. All SIAB1 and SafeLine containers were running; public and origin health returned HTTP 200. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d5ef5e2..3744595 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,8 +1,8 @@ # Arsitektur SIAB1 Dokumen ini membedakan arsitektur produksi yang teramati, komponen opsional yang sudah -tersedia di repository, dan target berikutnya. Basis status saat ini adalah commit `ff3bef9` -dan verifikasi produksi 2026-08-22. +tersedia di repository, dan target berikutnya. Basis status saat ini adalah closeout +student hot-path `2856e47` (Go image `siab1-go:373c131`) dan verifikasi VPS 2026-08-28. ## Pandangan Klien @@ -44,17 +44,18 @@ SafeLine (TLS/WAF publik) 127.0.0.1:8080 (loopback-only) | Nginx - _____|____________________ - | | -student data plane admin/control plane -api ... api8 api_admin, api_admin2 -FastAPI app.main FastAPI app.main - | | - +------------+-------------+ - | - PgBouncer transaction pool - | - PostgreSQL 15 + _____|______________________________________ + | | | +student hot-path student non-hot-path admin/control +go_server api ... api8 api_admin, api_admin2 +join/start/answer FastAPI app.main FastAPI app.main +autosave/batch/submit (fallback for the six) + | | | + +---------------------+----------------------+ + | + PgBouncer transaction pool + | + PostgreSQL 15 Redis 7 cache, lock, stream, dan koordinasi runtime Celery pekerjaan asinkron dan terjadwal @@ -62,30 +63,31 @@ Prometheus pengumpulan metrik Grafana visualisasi operasional ``` -- Seluruh trafik produksi saat ini ditangani FastAPI. -- Nginx memisahkan delapan lane peserta dari dua lane admin/control dan menerapkan limit khusus - untuk login, join, start, polling, submit, dan monitoring berat. -- Semua lane masih menjalankan modular monolith yang sama dari `app/main.py`. Isolasi yang aktif - adalah routing, rate limit, worker, dan kapasitas proses; inventory route aplikasi belum - dipisahkan per plane. +- Enam rute siswa (join, start, submit-answer, auto-save, auto-save-batch, submit) dirutekan + 100% ke `go_start_backend` (`go_server:8000`, replica `go-start`). FastAPI tetap map default + dan `server api:8000 resolve backup`. +- Login, poll, export, admin, guru, Pengawas, dan sisa API tetap FastAPI (`api`…`api8` plus + `api_admin` / `api_admin2`). +- Nginx memisahkan lane peserta dari lane admin/control dan menerapkan limit khusus untuk login, + join, start, polling, submit, dan monitoring berat. +- Satu sesi punya satu writer. Dual-write jawaban ke Go dan FastAPI dilarang. Rollback adalah + swap file canary per rute di `runtime_control/`, bukan `docker compose down -v`. - PgBouncer memakai transaction pooling. Operasi database tidak boleh bergantung pada state koneksi yang bertahan di luar satu transaksi. - PostgreSQL adalah source of truth. Redis menyimpan state turunan, cache, lock, stream, - koordinasi, dan buffer opsional; kehilangan Redis tidak boleh menghilangkan jawaban yang telah + koordinasi, dan buffer; kehilangan Redis tidak boleh menghilangkan jawaban yang telah diakui durable. - SafeLine adalah satu-satunya ingress publik. Nginx origin tidak diekspos langsung ke internet. ## Komponen Opsional -### Go Native-Lean +### Go worker dan Compose profile -`go_server` dan `go_worker` tersedia di Compose melalui profile `native-lean`, tetapi tidak aktif -di VPS dan bukan upstream Nginx produksi. Go saat ini berstatus **implemented/optional**, bukan -**canary**, **production**, atau **primary**. +`go_server` produksi berjalan sebagai image `siab1-go:373c131`. Repo Compose masih menandai +service itu di profile `native-lean`; komentar profile bukan topologi live. -Go tidak boleh disebut menangani trafik produksi sampai routing Nginx, container aktif, revision, -health, contract parity, metrik, dan hasil rekonsiliasi membuktikannya. Broad proxy fallback dari -Go ke Python, request-level runtime switching, dan dual-write jawaban tidak menjadi target. +`go_worker` tetap opsional dan bukan bagian closeout hot-path. Jangan mengaktifkannya tanpa +bukti kebutuhan dan runbook. ### Read Replica @@ -95,41 +97,32 @@ runbook failover dan recovery. ## Target Berikutnya -Target terdekat adalah **plane-aligned FastAPI modular monolith**, bukan migrasi bahasa atau -microservices langsung. +Hot-path siswa sudah hybrid di Nginx: Go primary, FastAPI fallback. Target berikutnya adalah +**plane-aligned composition untuk sisa FastAPI**, bukan membalik enam rute ke Python dan bukan +microservices. ```text SafeLine -> Nginx - -> student FastAPI composition -> ExamRuntime - -> control FastAPI composition -> control capabilities + ExamRuntime + -> student hot-path Go (enam rute) + FastAPI backup + -> student FastAPI composition (login, poll, non-hot-path) + -> control FastAPI composition -> control capabilities -ExamRuntime +Kedua runtime -> PostgreSQL adapter -> PgBouncer -> PostgreSQL -> Redis adapter -> cache/lock/stream/coordination -> post-commit monitoring events ``` -- Buat composition root student yang hanya memuat route dan middleware data plane. -- Buat composition root control yang memuat admin, guru, Pengawas, monitoring, export, dan - operasi sistem. -- Gunakan satu implementasi domain `ExamRuntime` untuk kedua plane agar policy sesi, integritas, - answer merge, locking, final-submit, scoring, dan audit tidak bercabang. -- Pertahankan satu image, satu repository, satu schema, PostgreSQL, dan Redis pada VPS saat ini. - Pemisahan ini adalah boundary proses dan capability, bukan distributed microservices. -- Pertahankan kontrak HTTP yang ada melalui adapter tipis. Migrasi dilakukan satu operasi lengkap - per tahap dan harus tetap dapat dikembalikan ke `app.main:app`. - -Boundary ini dipilih karena menyembunyikan kompleksitas konsistensi ujian di balik satu interface -kecil. Alternatif hybrid Go/FastAPI ditolak sebagai langkah langsung karena menambah kontrak -lintas bahasa, RPC internal, write-owner switching, dan risiko rollback sebelum bottleneck Python -terukur. Ide yang dipertahankan dari alternatif tersebut adalah contract fixture lintas runtime, -canary per sesi, larangan dual-write, dan rollback melalui routing yang deterministik. +- Pertahankan kontrak HTTP yang ada. Klien tidak memilih runtime. +- Tambahan rute ke Go hanya setelah gerbang di bawah lulus. Enam rute closeout tidak diulang + dari nol kecuali regresi. +- Satu repository, satu schema, PostgreSQL, dan Redis pada VPS saat ini. ## Gerbang Performa dan Go Tidak ada runtime yang boleh diklaim lebih cepat berdasarkan jumlah worker, container sehat, -bahasa implementasi, atau unit test. Promosi Go hanya dipertimbangkan untuk bottleneck yang telah -diukur dan harus melewati seluruh gerbang berikut: +bahasa implementasi, atau unit test. Promosi rute Go tambahan harus melewati seluruh gerbang +berikut. Enam rute closeout sudah melewatinya pada 2026-08-28: 1. Backup otomatis tersedia dan restore drill berhasil. 2. Revision deployment dapat dibuktikan dan restart policy disetujui. @@ -206,8 +199,10 @@ Bukti agregat tersedia di ## Entry Point - FastAPI: `app/main.py` -- Go API opsional: `go/cmd/server/main.go` +- Go hot-path: `go/cmd/server/main.go` - Go worker opsional: `go/cmd/worker/main.go` +- Nginx canary live: `runtime_control/nginx.{start,join,answer,autosave,batch,submit}-canary.conf` +- Closeout probe: `scripts/go_hotpath_lifecycle.py` - Android: `android-kiosk/app/src/main/java/id/siab1/kiosk/` - Flutter: `flutter_client_code/lib/main.dart` - Compose: `docker-compose.production.yml` diff --git a/README.md b/README.md index f0ee612..ee91998 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ **Sistem Informasi Asesmen Berintegritas** untuk penyelenggaraan asesmen digital yang terlindungi, terpantau, dan dapat diaudit. -SIAB1 menggabungkan aplikasi kiosk Android, runtime FastAPI, PostgreSQL, Redis, Celery, -serta kontrol SEB/SXB untuk menjaga integritas sesi asesmen dari login sampai pelaporan. Runtime -Go Native-Lean tersedia sebagai komponen opsional dan belum menerima trafik produksi. +SIAB1 menggabungkan aplikasi kiosk Android, FastAPI untuk control/non-hot-path, Go untuk +enam rute siswa (join, start, submit-answer, auto-save, auto-save-batch, submit), PostgreSQL, +Redis, Celery, serta kontrol SEB/SXB. FastAPI tetap fallback Nginx untuk rute tersebut. ## Kemampuan Utama @@ -15,8 +15,8 @@ Go Native-Lean tersedia sebagai komponen opsional dan belum menerima trafik prod - Autosave, answer journal, final-submit integrity, reconnect, dan recovery sesi. - Validasi SEB/SXB, signature policy, rate limiting, CAPTCHA, account lockout, dan audit logging. - Monitoring real-time, Prometheus, Grafana, alerting, backup, dan recovery tooling. -- FastAPI sebagai runtime produksi dengan jalur Go Native-Lean opsional yang harus melewati - contract, load, canary, dan rollback gates sebelum dipromosikan. +- Go sebagai writer produksi untuk enam rute siswa; FastAPI menangani control plane dan + menjadi backup Nginx. Dual-write jawaban dilarang. ## Arsitektur @@ -24,15 +24,19 @@ Go Native-Lean tersedia sebagai komponen opsional dan belum menerima trafik prod Android kiosk / browser / Flutter fallback | SafeLine -> Nginx - | - FastAPI student + control lanes - | - PgBouncer / Redis - | - PostgreSQL / Celery workers + _________|_________ + | | + Go student hot-path FastAPI lanes + (6 routes, 100%) + FastAPI backup + | | + +---------+---------+ + | + PgBouncer / Redis + | + PostgreSQL / Celery workers ``` -Status produksi, komponen opsional, dan target boundary tersedia di +Status produksi, fallback, dan target boundary tersedia di [ARCHITECTURE.md](ARCHITECTURE.md). ## Struktur Repository @@ -41,7 +45,7 @@ Status produksi, komponen opsional, dan target boundary tersedia di |---|---| | `android-kiosk/` | Klien Android native utama | | `app/` | API, policy, model, service, middleware, dan task FastAPI | -| `go/` | Kandidat runtime Go Native-Lean opsional; bukan upstream produksi | +| `go/` | Runtime Go student hot-path (primary untuk enam rute) | | `flutter_client_code/` | Klien Flutter fallback | | `templates/`, `static/` | Antarmuka web admin dan peserta | | `docker/`, `monitoring/` | Container, Nginx, PgBouncer, Prometheus, dan Grafana | diff --git a/docs/HISTORY.md b/docs/HISTORY.md index bfa2fb2..695dc39 100644 --- a/docs/HISTORY.md +++ b/docs/HISTORY.md @@ -5,7 +5,8 @@ Repository ini dikonsolidasikan dari implementasi lama menjadi SIAB1 dengan hist ## Perubahan Utama - Backend FastAPI di-hardening untuk autentikasi, rate limiting, audit, autosave, submit, recovery, dan operasi asesmen. -- Jalur Go Native-Lean ditambahkan untuk route utama dengan fallback FastAPI. +- Enam rute siswa (join, start, submit-answer, auto-save, auto-save-batch, submit) menjadi + Go primary di produksi dengan FastAPI sebagai backup Nginx; dual-write jawaban dilarang. - Android kiosk native menjadi klien utama; Flutter dipertahankan sebagai fallback. - Frontend monolit dipecah menjadi module dengan bundle reproducible. - Monitoring, backup, capacity guard, dan release gate ditambahkan. From 2d1b5b5402b758670090c070f0d5f73ff4fec244 Mon Sep 17 00:00:00 2001 From: SIAB1 Operations Date: Fri, 28 Aug 2026 00:51:58 +0700 Subject: [PATCH 8/8] test: align CI contracts with native student hot-path --- tests/test_canary_api8_isolation.py | 5 ++++- tests/test_go_exam_write_proxy.py | 33 ++++++++++++++++++---------- tests/test_nginx_static_offload.py | 18 ++++++++++----- tests/test_secure_admin_bootstrap.py | 3 ++- 4 files changed, 39 insertions(+), 20 deletions(-) diff --git a/tests/test_canary_api8_isolation.py b/tests/test_canary_api8_isolation.py index 706b16c..74fbd49 100644 --- a/tests/test_canary_api8_isolation.py +++ b/tests/test_canary_api8_isolation.py @@ -42,8 +42,11 @@ def test_canary_api8_replaces_app_mount_and_pins_n4() -> None: def test_production_control_plane_keeps_shared_app_mount() -> None: assert "./app:/app/app" in PRODUCTION_COMPOSE - assert "START_DB_ADMISSION_LIMIT" not in PRODUCTION_COMPOSE assert "/opt/siab1-canary/app:/app/app" not in PRODUCTION_COMPOSE + api_block = PRODUCTION_COMPOSE.split("x-api-service: &api-service", 1)[1].split( + "\n api:", 1 + )[0] + assert "START_DB_ADMISSION_LIMIT" not in api_block def test_nginx_logs_upstream_identity_and_status_chain() -> None: diff --git a/tests/test_go_exam_write_proxy.py b/tests/test_go_exam_write_proxy.py index 76f376e..52bb537 100644 --- a/tests/test_go_exam_write_proxy.py +++ b/tests/test_go_exam_write_proxy.py @@ -5,31 +5,40 @@ EXAM = ROOT / "go" / "internal" / "exam" +def _handler_body(src: str, name: str) -> str: + marker = f"func (d deps) {name}(w http.ResponseWriter, r *http.Request) {{" + assert marker in src, name + return src.split(marker, 1)[1].split("\n}", 1)[0] + + def test_non_start_student_exam_write_handlers_proxy_instead_of_local_mutation() -> None: http_src = (EXAM / "http.go").read_text(encoding="utf-8") start_src = (EXAM / "start_native.go").read_text(encoding="utf-8") - submit_src = (EXAM / "submit.go").read_text(encoding="utf-8") + join_src = (EXAM / "join_native.go").read_text(encoding="utf-8") + answer_src = (EXAM / "answer_native.go").read_text(encoding="utf-8") + autosave_src = (EXAM / "autosave_native.go").read_text(encoding="utf-8") + submit_src = (EXAM / "submit_native.go").read_text(encoding="utf-8") runtime_src = (EXAM / "runtime.go").read_text(encoding="utf-8") assert "func (d deps) proxyExamWrite" in http_src for src, name in ( - (http_src, "autoSave"), - (http_src, "submitAnswer"), - (submit_src, "submitExam"), - (runtime_src, "autoSaveBatch"), (runtime_src, "journalSync"), (runtime_src, "logViolation"), ): - marker = f"func (d deps) {name}(w http.ResponseWriter, r *http.Request) {{" - assert marker in src, name - body = src.split(marker, 1)[1].split("\n}", 1)[0] - assert "d.proxyExamWrite(w, r)" in body, name + assert "d.proxyExamWrite(w, r)" in _handler_body(src, name), name + + for src, name in ( + (start_src, "startExam"), + (join_src, "joinExam"), + (answer_src, "submitAnswer"), + (autosave_src, "autoSave"), + (autosave_src, "autoSaveBatch"), + (submit_src, "submitExam"), + ): + assert "d.proxyExamWrite(w, r)" not in _handler_body(src, name), name assert "UpsertAnswer" not in http_src assert "UpsertAnswer" not in runtime_src - start_fn = start_src.split("func (d deps) startExam", 1)[1].split("\n}", 1)[0] - assert "d.proxyExamWrite(w, r)" not in start_fn - assert "BeginSubmit" not in submit_src assert "LogViolation" not in runtime_src diff --git a/tests/test_nginx_static_offload.py b/tests/test_nginx_static_offload.py index 4a9594b..b28fe6d 100644 --- a/tests/test_nginx_static_offload.py +++ b/tests/test_nginx_static_offload.py @@ -78,14 +78,20 @@ def test_only_exam_start_canary_can_reach_go() -> None: start = _location_block("~ ^/api/exams/[0-9]+/start$") assert "proxy_pass http://$start_backend" in start assert "http_500" in start - for path in ( - "= /api/exams/submit-answer", - "= /api/exams/submit", - "/api/", - ): + routed = ( + ("= /api/exams/join", "$join_backend"), + ("= /api/exams/submit-answer", "$answer_backend"), + ("= /api/exams/auto-save", "$autosave_backend"), + ("= /api/exams/auto-save-batch", "$batch_backend"), + ("= /api/exams/submit", "$submit_backend"), + ) + for path, backend in routed: block = _location_block(path) - assert "proxy_pass http://fastapi_backend" in block + assert f"proxy_pass http://{backend}" in block assert "go_server" not in block + fallback = _location_block("/api/") + assert "proxy_pass http://fastapi_backend" in fallback + assert "go_server" not in fallback def test_go_start_uses_scored_pgbouncer_settings_and_n4() -> None: diff --git a/tests/test_secure_admin_bootstrap.py b/tests/test_secure_admin_bootstrap.py index 3b3c7c7..c740bae 100644 --- a/tests/test_secure_admin_bootstrap.py +++ b/tests/test_secure_admin_bootstrap.py @@ -2,11 +2,12 @@ import os import subprocess +import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -PYTHON = ROOT / ".venv" / "bin" / "python" +PYTHON = Path(sys.executable) SCRIPT = ROOT / "scripts" / "bootstrap_admin.py"