Skip to content
Snippets Groups Projects
Commit 541a38ca authored by andrew farquharson's avatar andrew farquharson Committed by Stephen Mustgrave
Browse files

Resolve #3088468 "Quiz not redirecting"

parent 24dcecf9
No related branches found
No related tags found
1 merge request!41Resolve #3088468 "Quiz not redirecting"
Pipeline #280310 passed
......@@ -19,6 +19,7 @@ qqid
qqrs
qras
Resumeable
superglobal
tablesorter
Telemedicine
userto
......
......@@ -15,3 +15,8 @@ services:
arguments: [ '@current_route_match', '@entity_type.manager' ]
tags:
- { name: 'context_provider' }
quiz.event_subscriber:
class: Drupal\quiz\EventSubscriber\QuizAccessDeniedSubscriber
arguments: ['@http_kernel', '@logger.channel.php', '@redirect.destination', '@router.no_access_checks', '@current_route_match', '@quiz.session']
tags:
- { name: event_subscriber }
<?php
declare(strict_types=1);
namespace Drupal\quiz\EventSubscriber;
use Drupal\Core\EventSubscriber\DefaultExceptionHtmlSubscriber;
use Drupal\Core\Routing\RedirectDestinationInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Url;
use Drupal\quiz\Services\QuizSessionInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Routing\Matcher\UrlMatcherInterface;
/**
* If quiz session data becomes unavailable during a quiz go to quiz/quiz id.
*/
final class QuizAccessDeniedSubscriber extends DefaultExceptionHtmlSubscriber {
/**
* QuizAccessDeniedSubscriber constructor.
*
* @param \Symfony\Component\HttpKernel\HttpKernelInterface $http_kernel
* The wrapped HTTP kernel.
* @param \Psr\Log\LoggerInterface $logger
* A logger instance.
* @param \Drupal\Core\Routing\RedirectDestinationInterface $redirect_destination
* The redirect destination service.
* @param \Symfony\Component\Routing\Matcher\UrlMatcherInterface $access_unaware_router
* A router implementation which does not check access.
* @param \Drupal\Core\Routing\RouteMatchInterface $routeMatch
* The current route match.
* @param \Drupal\quiz\Services\QuizSessionInterface $quizSession
* The quiz session service.
*/
public function __construct(
HttpKernelInterface $http_kernel,
LoggerInterface $logger,
RedirectDestinationInterface $redirect_destination,
UrlMatcherInterface $access_unaware_router,
protected RouteMatchInterface $routeMatch,
protected QuizSessionInterface $quizSession,
) {
parent::__construct($http_kernel, $logger, $redirect_destination, $access_unaware_router);
}
/**
* {@inheritdoc}
*/
protected static function getPriority(): int {
return 10;
}
/**
* {@inheritdoc}
*/
public function on403(ExceptionEvent $event): void {
$route_name = $this->routeMatch->getRouteName();
$quiz_id = (int) $this->routeMatch->getRawParameter('quiz');
$session_sound = $this->quizSession->isSessionSound($quiz_id);
if ($route_name === 'quiz.question.take' && !$session_sound) {
$url = Url::fromRoute('entity.quiz.canonical', ['quiz' => $quiz_id]);
$url = $url->toString();
$response = new RedirectResponse($url);
$event->setResponse($response);
$event->stopPropagation();
}
}
}
......@@ -120,6 +120,14 @@ class QuizSession implements QuizSessionInterface {
}
}
/**
* {@inheritdoc}
*/
public function isSessionSound(int $quiz_id): bool {
$current_quizzes = $this->getCurrentQuizzes();
return !empty($current_quizzes[$quiz_id][self::RESULT_ID]) && !empty($current_quizzes[$quiz_id][self::CURRENT_QUESTION]);
}
/**
* Gets the current quizzes the user is taking.
*
......
......@@ -90,4 +90,12 @@ interface QuizSessionInterface {
*/
public function setCurrentQuestion(Quiz $quiz, int $current_question);
/**
* Checks for the survival of essential session variables during a quiz.
*
* @param int $quiz_id
* The quiz id extracted from the route.
*/
public function isSessionSound(int $quiz_id): bool;
}
......@@ -90,6 +90,7 @@ class QuizResumeTest extends QuizTestBase {
$this->drupalLogin($this->user);
$this->drupalGet("quiz/{$quiz_node->id()}/take");
$this->drupalGet("quiz/{$quiz_node->id()}/take/2");
$this->assertSession()->addressEquals("quiz/{$quiz_node->id()}/take/2");
$this->assertSession()->statusCodeEquals(403);
$this->drupalGet("quiz/{$quiz_node->id()}/take/1");
$this->submitForm([
......@@ -104,7 +105,7 @@ class QuizResumeTest extends QuizTestBase {
// Assert 2nd question is not accessible (indicating the answer to #1 was
// not saved.)
$this->drupalGet("quiz/{$quiz_node->id()}/take/2");
$this->assertSession()->statusCodeEquals(403);
$this->assertSession()->addressEquals("quiz/{$quiz_node->id()}");
}
}
......@@ -2,6 +2,7 @@
namespace Drupal\Tests\quiz\Functional;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\quiz\Util\QuizUtil;
......@@ -25,6 +26,90 @@ class QuizTakingTest extends QuizTestBase {
'quiz_truefalse',
];
/**
* Converts session data from the database into an array of data.
*
* This is necessary because PHP's methods write to the $_SESSION superglobal,
* and we want to work with session data that isn't ours.
*
* See https://stackoverflow.com/questions/530761/how-can-i-unserialize-session-data-to-an-arbitrary-variable-in-php
*
* @param string $session_data
* The serialized session data. (Note this is not the same serialization
* format as used by serialize().)
*
* @return array
* An array of data.
*/
protected function unserializePhp(string $session_data): array {
$return_data = [];
$offset = 0;
while ($offset < strlen($session_data)) {
if (!str_contains(substr($session_data, $offset), "|")) {
throw new \Exception("invalid data, remaining: " . substr($session_data, $offset));
}
$pos = strpos($session_data, "|", $offset);
$num = $pos - $offset;
$varname = substr($session_data, $offset, $num);
$offset += $num + 1;
$data = unserialize(substr($session_data, $offset));
$return_data[$varname] = $data;
$offset += strlen(serialize($data));
}
return $return_data;
}
/**
* Converts an array of data into a session data string.
*
* This is necessary because PHP's methods write to the $_SESSION superglobal,
* and we want to work with session data that isn't ours.
*
* See https://stackoverflow.com/questions/530761/how-can-i-unserialize-session-data-to-an-arbitrary-variable-in-php
*
* @param array $data
* The session data.
*
* @return string
* The serialized data. (Note this is not the same serialization format as
* used by serialize().)
*/
protected function serializePhp(array $data): string {
$return_data = '';
foreach ($data as $key => $key_data) {
$return_data .= "{$key}|" . serialize($key_data);
}
return $return_data;
}
/**
* Write data to the given session.
*
* This exists because we can't use
* \Drupal\Core\Session\SessionHandler::write() as that assumes the current
* session is being written, and will fail within tests as no session exists.
*
* @param int $uid
* The user ID.
* @param string $sid
* The session ID.
* @param string $value
* The session data. Use serializePhp() to format this.
*/
protected function writeSession(int $uid, string $sid, string $value): void {
$fields = [
'uid' => $uid,
'hostname' => 'testing',
'session' => $value,
'timestamp' => \Drupal::time()->getRequestTime(),
];
$this->container->get('database')->merge('sessions')
->keys(['sid' => Crypt::hashBase64($sid)])
->fields($fields)
->execute();
}
/**
* Test the quiz availability tests.
*/
......@@ -416,4 +501,53 @@ class QuizTakingTest extends QuizTestBase {
$this->assertSession()->fieldNotExists("edit-question-{$question2->id()}-is-doubtful");
}
/**
* Test if necessary session data disappears during a quiz.
*/
public function testQuestionSessionLossRedirect(): void {
$this->drupalLogin($this->admin);
$quiz_node = $this->createQuiz();
// 2 questions.
$question1 = $this->createQuestion([
'type' => 'truefalse',
'truefalse_correct' => 1,
]);
$this->linkQuestionToQuiz($question1, $quiz_node);
$question2 = $this->createQuestion([
'type' => 'truefalse',
'truefalse_correct' => 1,
]);
$this->linkQuestionToQuiz($question2, $quiz_node);
$this->drupalLogin($this->user);
$this->drupalGet("quiz/{$quiz_node->id()}/take");
$this->assertSession()->addressEquals("quiz/{$quiz_node->id()}/take/1");
$this->submitForm([
"question[{$question1->id()}][answer]" => '1',
], (string) $this->t('Next'));
// Go back to the previous question.
$this->drupalGet("quiz/{$quiz_node->id()}/take/1");
$this->assertSession()->addressEquals("quiz/{$quiz_node->id()}/take/1");
// Remove the session data.
$sid = $this->getSession()->getCookie($this->getSessionName());
$session_data = $this->container->get('session_handler.storage')->read($sid);
$session_data = $this->unserializePhp($session_data);
// Unsetting $session_data['_sf2_attributes']['quiz'][1]['current_question']
// has no effect. Whereas this should trigger 'access denied' or a redirect.
unset($session_data['_sf2_attributes']['quiz'][1]['result_id']);
// Write the removed session data back to the database.
$this->writeSession($this->user->id(), $sid, $this->serializePhp($session_data));
// Try to go to the next question.
$this->submitForm([
"question[{$question1->id()}][answer]" => '1',
], (string) $this->t('Next'));
// Find if the user is redirected to quiz/quiz id.
$this->assertSession()->addressEquals("quiz/{$quiz_node->id()}");
// Confirm that this is not a 403.
$this->assertSession()->pageTextNotContains("Access denied");
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment