Skip to content

Verify quiz answers server-side in reveal mode to prevent answer leakage - #3057

Merged
shewa12 merged 22 commits into
4.2.0from
fix-quiz-answer-reveal
Oct 14, 2026
Merged

shewa12 merged 22 commits into
4.2.0from
fix-quiz-answer-reveal

Conversation

@sazedul-haque

@sazedul-haque sazedul-haque commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

📌 Summary

Enhances the Quiz Answer Reveal feature by migrating to secure server-side answer verification, persisting revealed answer states across page reloads, and adding a countdown fill animation to navigation buttons during the reveal wait time.


🚀 Key Changes

  • Secure Server-Side Verification:

    • Removed client-side answer leakage (window.tutor_quiz_context and pre-rendered correct answer IDs).
    • Added secure wp_ajax_tutor_quiz_check_answer endpoint to verify questions on demand.
  • State Persistence on Reload:

    • Saves verified answers to tutor_quiz_attempt_answers immediately upon reveal.
    • Hydrates question selections, explanation blocks, and active question indexes when the page is reloaded.
    • Preserves pagination item states (correct / incorrect) across reloads by standardizing the data-reveal-result attribute.
  • Button Countdown Animation:

    • Added smooth background fill countdown animation to Next and Submit Quiz buttons during answers_reveal_duration.
    • Supports RTL layouts and respects prefers-reduced-motion.
  • Legacy Mode Parity & Bug Fixes:

    • Added loading spinners during verification in legacy mode.
    • Fixed legacy quiz pagination active state not updating when clicking the Back button.

…r leakage

- Remove pre-shipped encoded correct answer IDs and explanations from HTML templates and window context
- Add secure `wp_ajax_tutor_quiz_check_answer` endpoint to verify answers per-question on demand
- Integrate verification into modern Learning Area and decouple layout, submission, and explanation modules
- Prevent in-flight double-click race conditions with verification state guards on navigation and submit buttons
- Fix `quiz_answer_display_time` resolution from `window.TutorCore.config`
- Lock question inputs upon reveal in legacy mode and re-enable before submission to preserve attempt grading
@sazedul-haque sazedul-haque changed the title fix(quiz): Verify answers server-side in reveal mode to prevent answe… Verify answers server-side in reveal mode to prevent answer leakage Oct 5, 2026
@sazedul-haque sazedul-haque changed the title Verify answers server-side in reveal mode to prevent answer leakage Verify quiz answers server-side in reveal mode to prevent answer leakage Oct 5, 2026
@harunollyo

Copy link
Copy Markdown
Collaborator

This solution still has a glitch when fetching attempt answers by extracting the required IDs from the DOM. We need to do further R&D to find a proper solution and determine a target version for ending legacy support.

Comment thread assets/src/js/front/course/_spotlight-quiz.js Outdated
Comment thread classes/Quiz.php
Comment thread classes/Quiz.php Outdated
Comment thread classes/Quiz.php Outdated
Comment thread classes/Quiz.php Outdated
Comment thread classes/Quiz.php Outdated
Comment thread classes/Quiz.php Outdated
Safely encode the Alpine.js configuration used by the quiz attempt form by switching the `x-data` attribute to a double-quoted string and escaping embedded values with `esc_js`, `esc_attr`, and `tutor_json_encode`. This prevents malformed JavaScript when quiz IDs, form IDs, layout names, and default values contain quotes or special characters.
- Persist committed answers to tutor_quiz_attempt_answers upon verification
- Add QuizModel methods for committed answers and aggregated stats
- Hydrate answered questions, correct answer badges, and active index on page reload
- Prevent empty verification AJAX calls and scope UI resets in legacy quiz mode
- Seed question random ordering deterministically per attempt
…acy mode

- Re-enable Next and Submit buttons once answer verification response is rendered
- Clear reveal timeout when clicking Next, Previous, or Pagination items
- Allow students to advance immediately without waiting for full reveal duration
…in legacy mode

- Add is-loading class when triggering tutor_quiz_check_answer AJAX
- Remove is-loading class once feedback is rendered
This updates the quiz flow to clear pending submit timers while navigating, prevent empty-answer submissions, and avoid blocking navigation while a submit is pending. It also aligns the UI state handling for verification and submit buttons, and fixes focus/hover styling so disabled quiz options no longer appear active.
Update the revealed quiz question markup to use the `data-reveal-result` attribute instead of `data-result`, so the reveal result is exposed consistently for client-side logic and scripts.
@sazedul-haque
sazedul-haque changed the base branch from dev to 4.2.0 October 8, 2026 06:05
Comment thread classes/Quiz.php Outdated
Comment thread assets/src/js/front/course/_spotlight-quiz.js Outdated
Comment thread classes/Quiz.php Outdated
Comment thread classes/Quiz.php
Comment thread classes/Quiz.php
Comment thread classes/Quiz.php
);

$answers_data = apply_filters( 'tutor_filter_quiz_answer_data', $answers_data, $question_id, $question->question_type, $user_id, $attempt_id );

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is there no tutor_filter_quiz_total_marks filter? Don't we need it?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need this here. For reveal mode we are fetching the total marks from database before inserting quit attempt using QuizModel::get_attempt_answers_stats( (int) $attempt_id );:

if ( $has_committed_answers ) {
    $stats                    = QuizModel::get_attempt_answers_stats( (int) $attempt_id );
    $earned_marks             = max( 0.0, $stats->total_earned_marks );
    $total_answered_questions = $stats->total_answered_count;
    $review_required          = $review_required || $stats->pending_review_count > 0;
}

Comment thread classes/Quiz.php Outdated
Comment thread classes/Quiz.php

$wpdb->insert( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answers_data );

$explanation = apply_filters( 'tutor_quiz_question_answer_explanation', '', $question, $attempt );

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is the default value empty instead of the stored value?

@sazedul-haque sazedul-haque Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answer explanation comes from Pro plugin.

Comment thread classes/Quiz.php
$this->json_response( __( 'Answer has already been committed for this question', 'tutor' ), null, HttpHelper::STATUS_BAD_REQUEST );
}

$is_correct = false;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is the default value for is_correct false? What if the question requires manual review, like a short answer or an open-ended question?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reveal mode only supports following question types. So no manual review required.

$supported_types = array(
	QuizModel::QUESTION_TYPE_TRUE_FALSE,
	QuizModel::QUESTION_TYPE_SINGLE_CHOICE,
	QuizModel::QUESTION_TYPE_MULTIPLE_CHOICE,
);

Comment thread classes/Quiz.php Outdated
Comment thread classes/Quiz.php
'question_mark' => $question_mark,
'achieved_mark' => $achieved_mark,
'minus_mark' => 0,
'is_correct' => $is_correct ? QuizModel::ATTEMPT_ANSWER_CORRECT : QuizModel::ATTEMPT_ANSWER_INCORRECT,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the default value is set to = false
The question types that require manual review will show as incorrect by default?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned before these are the only supported question types for reveal mode.

$supported_types = array(
	QuizModel::QUESTION_TYPE_TRUE_FALSE,
	QuizModel::QUESTION_TYPE_SINGLE_CHOICE,
	QuizModel::QUESTION_TYPE_MULTIPLE_CHOICE,
);

Comment thread assets/src/js/front/course/_spotlight-quiz.js Outdated
Refactors spotlight quiz front-end logic in _spotlight-quiz.js: replace var with let/const, centralize form submission into submitQuizForm(), and introduce a single revealTimeoutId lifecycle (clear/set) to prevent races. Move loading/countdown handling into AJAX success paths, use get_reveal_wait_time() consistently, and switch complete callbacks to error to avoid duplicate post-request behavior. Overall improves scoping, prevents double-submits, and makes reveal/countdown UX more reliable.
This change hardens quiz answer reveal handling by requiring the attempt to still be in progress, using the attempt's quiz ID instead of a client-provided value, and validating supported question types before revealing answers. It also returns clearer errors for unsupported question types, disabled answer reveal, and failed answer insert attempts to avoid saving invalid quiz data.
Comment thread models/QuizModel.php
*
* @return object{total_earned_marks: float, total_answered_count: int, pending_review_count: int}
*/
public static function get_attempt_answers_stats( int $attempt_id ): object {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is already a function calculate_attempt_earned_marks; we can expand that. Also, why are we keeping track of pending_review_count? This is already tracked by manage_attempt_answers

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have removed the pending_review_count as there is no use case of it here. But if we want to expand calculate_attempt_earned_marks it requires changes in multiple places including return type. Instead we can update calculate_attempt_earned_marks as following. what do you say?

/**
 * Calculate total earned marks for an attempt from individual answer records.
 *
 * @since 4.1.0
 *
 * @param int $attempt_id Attempt ID.
 *
 * @return float
 */
private function calculate_attempt_earned_marks( int $attempt_id ): float {
    return QuizModel::get_attempt_answers_stats( $attempt_id )->total_earned_marks;
}

Loop through all attempt question IDs so questions with no submitted inputs are properly saved as skipped
This removes the pending-review flag from aggregated quiz attempt answer stats and stops setting review_required when any answer is still waiting for review. The stats query now reports only earned marks and answered count, simplifying the quiz attempt summary logic.
@shewa12
shewa12 merged commit 6e2cc97 into 4.2.0 Oct 14, 2026
10 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants