From 9f64c247241f2cda4b593b10f70c3c6ec9996b0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Mon, 9 Feb 2026 16:42:31 +0100 Subject: [PATCH 1/6] Performing a refresh of ReCodEx API token when frontend token is being refreshed. --- app/helpers/Recodex/RecodexApiHelper.php | 21 ++++++- app/presenters/LoginPresenter.php | 71 ++++++++++++++---------- app/security/AccessManager.php | 6 +- 3 files changed, 65 insertions(+), 33 deletions(-) diff --git a/app/helpers/Recodex/RecodexApiHelper.php b/app/helpers/Recodex/RecodexApiHelper.php index e83da5b..031566e 100644 --- a/app/helpers/Recodex/RecodexApiHelper.php +++ b/app/helpers/Recodex/RecodexApiHelper.php @@ -225,9 +225,9 @@ public function getTempTokenInstance(string $token): string /** * Complete the authentication process. Use tmp token to fetch full-token and user info. * The tmp token is expected to be set as the auth token already. - * @return array|null ['accessToken' => string, 'user' => RecodexUser] on success + * @return array ['accessToken' => string, 'user' => RecodexUser] on success */ - public function getTokenAndUser(): ?array + public function getTokenAndUser(): array { Debugger::log('ReCodEx::getTokenAndUser()', Debugger::DEBUG); $body = $this->post('extensions/' . $this->extensionId); @@ -240,6 +240,23 @@ public function getTokenAndUser(): ?array return $body; } + /** + * Refresh the token using ReCodEx API. The current token is expected to be set as the auth token already. + * @return array ['accessToken' => string, 'user' => RecodexUser] on success (same as getTokenAndUser()) + */ + public function refreshToken(): array + { + Debugger::log('ReCodEx::refreshToken()', Debugger::DEBUG); + $body = $this->post('login/refresh'); + if (!is_array($body) || empty($body['accessToken']) || empty($body['user'])) { + throw new RecodexApiException("Unexpected ReCodEx API response from token refresh endpoint."); + } + + // wrap the user into a structure + $body['user'] = new RecodexUser($body['user'], $this); + return $body; + } + /** * Retrieve user data. * @param string $id diff --git a/app/presenters/LoginPresenter.php b/app/presenters/LoginPresenter.php index 624da55..7ceb21c 100644 --- a/app/presenters/LoginPresenter.php +++ b/app/presenters/LoginPresenter.php @@ -4,7 +4,9 @@ use App\Exceptions\ForbiddenRequestException; use App\Exceptions\InvalidAccessTokenException; +use App\Exceptions\NotFoundException; use App\Exceptions\WrongCredentialsException; +use App\Model\Entity\User; use App\Model\Repository\Users; use App\Helpers\RecodexApiHelper; use App\Helpers\RecodexUser; @@ -12,6 +14,7 @@ use App\Security\Roles; use App\Security\TokenScope; use Nette\Security\AuthenticationException; +use Exception; /** * Endpoints used to log a user in @@ -42,6 +45,34 @@ class LoginPresenter extends BasePresenter */ public $roles; + /** + * Split the ReCodEx API token (save it to DB and suffix to the newly generated token), + * generate a new token for our frontend and send the response. + * @param User $user The user to log in + * @param string $token The token from ReCodEx API to split and save + */ + private function finalizeLogin(User $user, string $token): void + { + // part of the token is stored in the database, suffix goes into our token (payload) + $tokenSuffix = $user->setRecodexToken($token); + $user->updatedNow(); + $this->users->persist($user); + + // generate our token for our frontend + $token = $this->accessManager->issueToken( + $user, + null, // no effective role override + [TokenScope::MASTER, TokenScope::REFRESH], + null, // default expiration + ['suffix' => $tokenSuffix] + ); + + $this->sendSuccessResponse([ + "accessToken" => $token, + "user" => $user, + ]); + } + /** * Log in using temp token from ReCodEx. * @POST @@ -70,25 +101,8 @@ public function actionDefault() } else { $recodexUser->updateUser($user); } - $user->updatedNow(); - - // part of the token is stored in the database, suffix goes into our token (payload) - $tokenSuffix = $user->setRecodexToken($recodexResponse['accessToken']); - $this->users->persist($user); - - // generate our token for our frontend - $token = $this->accessManager->issueToken( - $user, - null, // no effective role override - [TokenScope::MASTER, TokenScope::REFRESH], - null, // default expiration - ['suffix' => $tokenSuffix] - ); - $this->sendSuccessResponse([ - "accessToken" => $token, - "user" => $user, - ]); + $this->finalizeLogin($user, $recodexResponse['accessToken']); } /** @@ -104,23 +118,24 @@ public function checkRefresh() } /** - * Refresh the access token of current user + * Refresh the access token of current user (as well as the ReCodEx API token). * @GET * @LoggedIn + * @throws AuthenticationException * @throws ForbiddenRequestException + * @throws NotFoundException + * @throws InvalidAccessTokenException */ public function actionRefresh() { - $token = $this->getAccessToken(); + $recodexResponse = $this->recodexApi->refreshToken(); + /** @var RecodexUser */ + $recodexUser = $recodexResponse['user']; - $user = $this->getCurrentUser(); - $this->users->flush(); + // Update the user entity with new info from ReCodEx. + $user = $this->users->findOrThrow($recodexUser->getId()); + $recodexUser->updateUser($user); - $this->sendSuccessResponse( - [ - "accessToken" => $this->accessManager->issueRefreshedToken($token), - "user" => $user, - ] - ); + $this->finalizeLogin($user, $recodexResponse['accessToken']); } } diff --git a/app/security/AccessManager.php b/app/security/AccessManager.php index 894520e..0316a0b 100644 --- a/app/security/AccessManager.php +++ b/app/security/AccessManager.php @@ -108,9 +108,9 @@ public function getUser(AccessToken $token): User */ public function issueToken( User $user, - string $effectiveRole = null, + ?string $effectiveRole = null, array $scopes = [], - int $exp = null, + ?int $exp = null, array $payload = [] ) { if ($exp === null) { @@ -155,7 +155,7 @@ public static function getGivenAccessToken(IRequest $request) { $accessToken = $request->getQuery("access_token"); if ($accessToken !== null && Strings::length($accessToken) > 0) { - return $accessToken; // the token specified in the URL is prefered + return $accessToken; // the token specified in the URL is preferred } // if the token is not in the URL, try to find the "Authorization" header with the bearer token From 8dcef66d32f5769715d34a3e3cbb1566ae47122a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Mon, 9 Feb 2026 16:49:59 +0100 Subject: [PATCH 2/6] Making sure that ReCodEx API call failures caused by invalid token are propagated to frontend and invalid token failure (so the logout is triggered). --- app/helpers/Recodex/RecodexApiHelper.php | 6 ++++++ app/presenters/LoginPresenter.php | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/helpers/Recodex/RecodexApiHelper.php b/app/helpers/Recodex/RecodexApiHelper.php index 031566e..f6e4a86 100644 --- a/app/helpers/Recodex/RecodexApiHelper.php +++ b/app/helpers/Recodex/RecodexApiHelper.php @@ -4,6 +4,7 @@ use App\Exceptions\ConfigException; use App\Exceptions\RecodexApiException; +use App\Exceptions\InvalidAccessTokenException; use App\Model\Entity\SisScheduleEvent; use App\Model\Entity\User; use Nette; @@ -129,10 +130,15 @@ private function prepareOptions(array $query = [], $body = null, array $headers * Decode and verify JSON body. * @return array|string|int|bool|null decoded JSON response * @throws RecodexApiException + * @throws InvalidAccessTokenException */ private function processJsonBody($response) { $code = $response->getStatusCode(); + if ($code === 401) { // unauthorized, token is probably invalid + throw new InvalidAccessTokenException("Unauthorized request to ReCodEx API. Token is probably invalid."); + } + if ($code !== 200) { Debugger::log("HTTP request to ReCodEx API failed (response $code).", Debugger::DEBUG); throw new RecodexApiException("HTTP request failed (response $code)."); diff --git a/app/presenters/LoginPresenter.php b/app/presenters/LoginPresenter.php index 7ceb21c..651398d 100644 --- a/app/presenters/LoginPresenter.php +++ b/app/presenters/LoginPresenter.php @@ -14,7 +14,6 @@ use App\Security\Roles; use App\Security\TokenScope; use Nette\Security\AuthenticationException; -use Exception; /** * Endpoints used to log a user in From ea41d93aadb5decf51a448e909ff4fd9f21af2f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Mon, 9 Feb 2026 17:10:44 +0100 Subject: [PATCH 3/6] Fixing bug - the refresh endpoint needs ReCodEx token properly injected. --- app/helpers/Recodex/RecodexApiHelper.php | 3 +++ app/presenters/LoginPresenter.php | 22 ++++++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/helpers/Recodex/RecodexApiHelper.php b/app/helpers/Recodex/RecodexApiHelper.php index f6e4a86..0ef4392 100644 --- a/app/helpers/Recodex/RecodexApiHelper.php +++ b/app/helpers/Recodex/RecodexApiHelper.php @@ -136,6 +136,7 @@ private function processJsonBody($response) { $code = $response->getStatusCode(); if ($code === 401) { // unauthorized, token is probably invalid + Debugger::log("HTTP request to ReCodEx API failed (response $code).", Debugger::DEBUG); throw new InvalidAccessTokenException("Unauthorized request to ReCodEx API. Token is probably invalid."); } @@ -238,6 +239,7 @@ public function getTokenAndUser(): array Debugger::log('ReCodEx::getTokenAndUser()', Debugger::DEBUG); $body = $this->post('extensions/' . $this->extensionId); if (!is_array($body) || empty($body['accessToken']) || empty($body['user'])) { + Debugger::log($body, Debugger::DEBUG); throw new RecodexApiException("Unexpected ReCodEx API response from extension token endpoint."); } @@ -255,6 +257,7 @@ public function refreshToken(): array Debugger::log('ReCodEx::refreshToken()', Debugger::DEBUG); $body = $this->post('login/refresh'); if (!is_array($body) || empty($body['accessToken']) || empty($body['user'])) { + Debugger::log($body, Debugger::DEBUG); throw new RecodexApiException("Unexpected ReCodEx API response from token refresh endpoint."); } diff --git a/app/presenters/LoginPresenter.php b/app/presenters/LoginPresenter.php index 651398d..5f104f1 100644 --- a/app/presenters/LoginPresenter.php +++ b/app/presenters/LoginPresenter.php @@ -54,7 +54,6 @@ private function finalizeLogin(User $user, string $token): void { // part of the token is stored in the database, suffix goes into our token (payload) $tokenSuffix = $user->setRecodexToken($token); - $user->updatedNow(); $this->users->persist($user); // generate our token for our frontend @@ -100,6 +99,7 @@ public function actionDefault() } else { $recodexUser->updateUser($user); } + $user->updatedNow(); $this->finalizeLogin($user, $recodexResponse['accessToken']); } @@ -127,13 +127,27 @@ public function checkRefresh() */ public function actionRefresh() { + // We need to inject the token manually here (this class is not derived from BasePresenterWithApi) + $user = $this->getCurrentUser(); + $prefix = $user->getRecodexToken(); + $suffix = $this->getAccessToken()->getPayloadOrDefault('suffix', null); + + if (!$prefix || !$suffix) { + throw new ForbiddenRequestException("Cannot refresh token - user does not have a ReCodEx token."); + } + + // Call ReCodEx API to refresh the token + $this->recodexApi->setAuthToken($prefix . $suffix); $recodexResponse = $this->recodexApi->refreshToken(); /** @var RecodexUser */ $recodexUser = $recodexResponse['user']; - // Update the user entity with new info from ReCodEx. - $user = $this->users->findOrThrow($recodexUser->getId()); - $recodexUser->updateUser($user); + // Update the user entity if the token uses the same identity as the user + // (token may use identity override, in which case we do not want to update the user) + if ($recodexUser->getId() === $user->getId()) { + $recodexUser->updateUser($user); + $user->updatedNow(); + } $this->finalizeLogin($user, $recodexResponse['accessToken']); } From 45cf42ce3ae2e04b34623d4e20d4294555791913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Mon, 9 Feb 2026 18:35:17 +0100 Subject: [PATCH 4/6] Proper exception handling for ReCodEx API requests. --- app/helpers/Recodex/RecodexApiHelper.php | 39 ++++++++++++++++++++---- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/app/helpers/Recodex/RecodexApiHelper.php b/app/helpers/Recodex/RecodexApiHelper.php index 0ef4392..8ae944e 100644 --- a/app/helpers/Recodex/RecodexApiHelper.php +++ b/app/helpers/Recodex/RecodexApiHelper.php @@ -161,17 +161,42 @@ private function processJsonBody($response) return $body['payload'] ?? null; } + /** + * Perform an HTTP request and return decoded JSON response. + * @param string $method HTTP method + * @param string $url suffix for the base URL + * @param array $options for GuzzleHttp request + * @return array|string|int|bool|null decoded JSON response + * @throws RecodexApiException + * @throws InvalidAccessTokenException + */ + private function request(string $method, string $url, array $options) + { + try { + $response = $this->client->request($method, $url, $options); + } catch (GuzzleHttp\Exception\ClientException $e) { + if ($e->hasResponse()) { + return $this->processJsonBody($e->getResponse()); + } + throw new RecodexApiException("HTTP request to ReCodEx API failed: " . $e->getMessage(), $e); + } catch (GuzzleHttp\Exception\GuzzleException $e) { + throw new RecodexApiException("HTTP request to ReCodEx API failed: " . $e->getMessage(), $e); + } + return $this->processJsonBody($response); + } + /** * Perform a GET request and return decoded JSON response. * @param string $url suffix for the base URL * @param array $params to be encoded in URL query * @param array $headers initial HTTP headers * @return array|string|int|bool|null decoded JSON response + * @throws RecodexApiException + * @throws InvalidAccessTokenException */ private function get(string $url, array $params = [], array $headers = []) { - $response = $this->client->get($url, $this->prepareOptions($params, null, $headers)); - return $this->processJsonBody($response); + return $this->request('GET', $url, $this->prepareOptions($params, null, $headers)); } /** @@ -181,11 +206,12 @@ private function get(string $url, array $params = [], array $headers = []) * @param string|array|null $body (array is encoded as JSON) * @param array $headers initial HTTP headers * @return array|string|int|bool|null decoded JSON response + * @throws RecodexApiException + * @throws InvalidAccessTokenException */ private function post(string $url, array $params = [], $body = null, array $headers = []) { - $response = $this->client->post($url, $this->prepareOptions($params, $body, $headers)); - return $this->processJsonBody($response); + return $this->request('POST', $url, $this->prepareOptions($params, $body, $headers)); } /** @@ -194,11 +220,12 @@ private function post(string $url, array $params = [], $body = null, array $head * @param array $params to be encoded in URL query * @param array $headers initial HTTP headers * @return array|string|int|bool|null decoded JSON response + * @throws RecodexApiException + * @throws InvalidAccessTokenException */ private function delete(string $url, array $params = [], array $headers = []) { - $response = $this->client->delete($url, $this->prepareOptions($params, null, $headers)); - return $this->processJsonBody($response); + return $this->request('DELETE', $url, $this->prepareOptions($params, null, $headers)); } /** From 02183dfb290560b07c2317a5263e6a0c4f8f7df0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Mon, 9 Feb 2026 18:39:54 +0100 Subject: [PATCH 5/6] Updating test mocks to reflect the last modification in ReCodEx request handling. --- tests/Presenters/GroupsPresenter.phpt | 84 +++++++++++++-------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/tests/Presenters/GroupsPresenter.phpt b/tests/Presenters/GroupsPresenter.phpt index 7462e15..7a6553f 100644 --- a/tests/Presenters/GroupsPresenter.phpt +++ b/tests/Presenters/GroupsPresenter.phpt @@ -126,7 +126,7 @@ class TestGroupsPresenter extends Tester\TestCase public function testListGroupsAll() { PresenterTestHelper::loginDefaultAdmin($this->container); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -166,7 +166,7 @@ class TestGroupsPresenter extends Tester\TestCase public function testListGroupsStudent() { PresenterTestHelper::login($this->container, PresenterTestHelper::STUDENT1_LOGIN); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -202,7 +202,7 @@ class TestGroupsPresenter extends Tester\TestCase public function testListGroupsTeacher() { PresenterTestHelper::login($this->container, PresenterTestHelper::TEACHER1_LOGIN); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -239,7 +239,7 @@ class TestGroupsPresenter extends Tester\TestCase $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); Assert::notNull($event); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -251,7 +251,7 @@ class TestGroupsPresenter extends Tester\TestCase ] ]))); - $this->client->shouldReceive("post")->with('group-attributes/g1', Mockery::any()) + $this->client->shouldReceive("request")->with('POST', 'group-attributes/g1', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -274,7 +274,7 @@ class TestGroupsPresenter extends Tester\TestCase $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); Assert::notNull($event); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -285,7 +285,7 @@ class TestGroupsPresenter extends Tester\TestCase ] ]))); - $this->client->shouldReceive("post")->with('group-attributes/t1', Mockery::any()) + $this->client->shouldReceive("request")->with('POST', 'group-attributes/t1', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -324,7 +324,7 @@ class TestGroupsPresenter extends Tester\TestCase $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); Assert::notNull($event); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -352,7 +352,7 @@ class TestGroupsPresenter extends Tester\TestCase $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); Assert::notNull($event); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -380,7 +380,7 @@ class TestGroupsPresenter extends Tester\TestCase $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); Assert::notNull($event); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -408,7 +408,7 @@ class TestGroupsPresenter extends Tester\TestCase $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); Assert::notNull($event); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -436,7 +436,7 @@ class TestGroupsPresenter extends Tester\TestCase $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); Assert::notNull($event); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -463,7 +463,7 @@ class TestGroupsPresenter extends Tester\TestCase $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); Assert::notNull($event); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -474,7 +474,7 @@ class TestGroupsPresenter extends Tester\TestCase ] ]))); - $this->client->shouldReceive("delete")->with('group-attributes/g1', Mockery::any()) + $this->client->shouldReceive("request")->with('DELETE', 'group-attributes/g1', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -497,7 +497,7 @@ class TestGroupsPresenter extends Tester\TestCase $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); Assert::notNull($event); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -507,7 +507,7 @@ class TestGroupsPresenter extends Tester\TestCase ] ]))); - $this->client->shouldReceive("delete")->with('group-attributes/g1', Mockery::any()) + $this->client->shouldReceive("request")->with('DELETE', 'group-attributes/g1', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -546,7 +546,7 @@ class TestGroupsPresenter extends Tester\TestCase $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); Assert::notNull($event); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -572,7 +572,7 @@ class TestGroupsPresenter extends Tester\TestCase $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); Assert::notNull($event); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -599,7 +599,7 @@ class TestGroupsPresenter extends Tester\TestCase $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); Assert::notNull($event); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -609,7 +609,7 @@ class TestGroupsPresenter extends Tester\TestCase ] ]))); - $this->client->shouldReceive("post")->with("groups/g1/students/$studentId", Mockery::any()) + $this->client->shouldReceive("request")->with('POST', "groups/g1/students/$studentId", Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -630,7 +630,7 @@ class TestGroupsPresenter extends Tester\TestCase { PresenterTestHelper::login($this->container, PresenterTestHelper::STUDENT1_LOGIN); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -656,7 +656,7 @@ class TestGroupsPresenter extends Tester\TestCase $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); Assert::notNull($event); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -684,7 +684,7 @@ class TestGroupsPresenter extends Tester\TestCase $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); Assert::notNull($event); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -695,7 +695,7 @@ class TestGroupsPresenter extends Tester\TestCase ] ]))); - $this->client->shouldReceive("post")->with('groups', Mockery::on(function ($arg) use ($user, $event) { + $this->client->shouldReceive("request")->with('POST', 'groups', Mockery::on(function ($arg) use ($user, $event) { Assert::type('array', $arg); Assert::type('array', $arg['json'] ?? null); $body = $arg['json']; @@ -723,14 +723,14 @@ class TestGroupsPresenter extends Tester\TestCase 'payload' => ['id' => 'g1'] ]))); - $this->client->shouldReceive("post")->with('groups/g1/members/' . $user->getId(), Mockery::any()) + $this->client->shouldReceive("request")->with('POST', 'groups/g1/members/' . $user->getId(), Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, 'payload' => "OK" ]))); - $this->client->shouldReceive("post")->with('group-attributes/g1', Mockery::any()) + $this->client->shouldReceive("request")->with('POST', 'group-attributes/g1', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -753,7 +753,7 @@ class TestGroupsPresenter extends Tester\TestCase $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); Assert::notNull($event); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -781,7 +781,7 @@ class TestGroupsPresenter extends Tester\TestCase $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); Assert::notNull($event); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -816,7 +816,7 @@ class TestGroupsPresenter extends Tester\TestCase 'en' => ['name' => 'Group', 'description' => 'Group description'], ]; - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -826,7 +826,7 @@ class TestGroupsPresenter extends Tester\TestCase ] ]))); - $this->client->shouldReceive("post")->with('groups', Mockery::on(function ($arg) use ($user, $texts) { + $this->client->shouldReceive("request")->with('POST', 'groups', Mockery::on(function ($arg) use ($user, $texts) { Assert::type('array', $arg); Assert::type('array', $arg['json'] ?? null); $body = $arg['json']; @@ -854,7 +854,7 @@ class TestGroupsPresenter extends Tester\TestCase 'payload' => ['id' => 'g1'] ]))); - $this->client->shouldReceive("post")->with('group-attributes/g1', Mockery::any()) + $this->client->shouldReceive("request")->with('POST', 'group-attributes/g1', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -885,7 +885,7 @@ class TestGroupsPresenter extends Tester\TestCase 'en' => ['name' => 'Group', 'description' => 'Group description'], ]; - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -919,7 +919,7 @@ class TestGroupsPresenter extends Tester\TestCase 'en' => ['name' => 'Group', 'description' => 'Group description'], ]; - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -945,7 +945,7 @@ class TestGroupsPresenter extends Tester\TestCase { PresenterTestHelper::loginDefaultAdmin($this->container); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -954,7 +954,7 @@ class TestGroupsPresenter extends Tester\TestCase ] ]))); - $this->client->shouldReceive("post")->with('group-attributes/g1', Mockery::on(function ($arg) { + $this->client->shouldReceive("request")->with('POST', 'group-attributes/g1', Mockery::on(function ($arg) { Assert::type('array', $arg); Assert::type('array', $arg['json'] ?? null); $body = $arg['json']; @@ -984,7 +984,7 @@ class TestGroupsPresenter extends Tester\TestCase { PresenterTestHelper::loginDefaultAdmin($this->container); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -1008,7 +1008,7 @@ class TestGroupsPresenter extends Tester\TestCase { PresenterTestHelper::loginDefaultAdmin($this->container); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -1032,7 +1032,7 @@ class TestGroupsPresenter extends Tester\TestCase { PresenterTestHelper::loginDefaultAdmin($this->container); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -1041,7 +1041,7 @@ class TestGroupsPresenter extends Tester\TestCase ] ]))); - $this->client->shouldReceive("delete")->with('group-attributes/g1', Mockery::on(function ($arg) { + $this->client->shouldReceive("request")->with('DELETE', 'group-attributes/g1', Mockery::on(function ($arg) { Assert::type('array', $arg); Assert::type('array', $arg['query'] ?? null); $query = $arg['query']; @@ -1071,7 +1071,7 @@ class TestGroupsPresenter extends Tester\TestCase { PresenterTestHelper::loginDefaultAdmin($this->container); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -1095,7 +1095,7 @@ class TestGroupsPresenter extends Tester\TestCase { PresenterTestHelper::loginDefaultAdmin($this->container); - $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + $this->client->shouldReceive("request")->with('GET', 'group-attributes', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, @@ -1119,7 +1119,7 @@ class TestGroupsPresenter extends Tester\TestCase { PresenterTestHelper::loginDefaultAdmin($this->container); - $this->client->shouldReceive("post")->with('groups/g1/archived', Mockery::any()) + $this->client->shouldReceive("request")->with('POST', 'groups/g1/archived', Mockery::any()) ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'success' => true, 'code' => 200, From c8e7df6084f07b8b7c1c01d34b38630ed9f37c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Tue, 10 Feb 2026 18:14:19 +0100 Subject: [PATCH 6/6] Better HTTP response in case the login token is invalid. --- app/presenters/LoginPresenter.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/presenters/LoginPresenter.php b/app/presenters/LoginPresenter.php index 5f104f1..472acc8 100644 --- a/app/presenters/LoginPresenter.php +++ b/app/presenters/LoginPresenter.php @@ -2,9 +2,11 @@ namespace App\Presenters; +use App\Exceptions\BadRequestException; use App\Exceptions\ForbiddenRequestException; use App\Exceptions\InvalidAccessTokenException; use App\Exceptions\NotFoundException; +use App\Exceptions\RecodexApiException; use App\Exceptions\WrongCredentialsException; use App\Model\Entity\User; use App\Model\Repository\Users; @@ -84,7 +86,11 @@ public function actionDefault() { $req = $this->getRequest(); $tempToken = $req->getPost("token"); - $instanceId = $this->recodexApi->getTempTokenInstance($tempToken); + try { + $instanceId = $this->recodexApi->getTempTokenInstance($tempToken); + } catch (RecodexApiException $e) { + throw new BadRequestException("Invalid token from ReCodEx API", $e); + } // Call ReCodEx API and get full token + user info using the temporary token $this->recodexApi->setAuthToken($tempToken);