diff --git a/lib/GaletteHelloasso/Controllers/HelloassoController.php b/lib/GaletteHelloasso/Controllers/HelloassoController.php index 9b8706d..f4273a2 100644 --- a/lib/GaletteHelloasso/Controllers/HelloassoController.php +++ b/lib/GaletteHelloasso/Controllers/HelloassoController.php @@ -92,15 +92,38 @@ public function formCheckout(Request $request, Response $response): Response $helloasso_request = $request->getParsedBody(); $helloasso = new Helloasso($this->zdb, $this->preferences); $adherent = new Adherent($this->zdb); - $contribution_type = new ContributionsTypes($this->zdb, (int)$helloasso_request['item_id']); - // Check the amount - $item_id = $helloasso_request['item_id']; + // Only reasons proposed to the current user can be paid + $item_id = (int)($helloasso_request['item_id'] ?? 0); $helloasso_amounts = $helloasso->getAmounts($this->login); - $amount = $helloasso_request['amount']; + if (!isset($helloasso_amounts[$item_id])) { + $this->flash->addMessage( + 'error_detected', + _T("You have to select an option.", "helloasso") + ); + + return $response + ->withStatus(301) + ->withHeader('Location', $this->routeparser->urlFor('helloasso_form')); + } + $contribution_type = new ContributionsTypes($this->zdb, $item_id); + + // Check the amount, accepting a decimal comma + $amount = $helloasso_request['amount'] ?? ''; + $amount = is_string($amount) ? str_replace(',', '.', trim($amount)) : ''; + if (!is_numeric($amount)) { + $this->flash->addMessage( + 'error_detected', + _T("Please enter an amount.", "helloasso") + ); + + return $response + ->withStatus(301) + ->withHeader('Location', $this->routeparser->urlFor('helloasso_form')); + } $amount_check = $helloasso_amounts[$item_id]['amount']; - if ($amount < $amount_check) { + if ((float)$amount < (float)$amount_check) { $this->flash->addMessage( 'error_detected', _T("The amount you've entered is lower than the minimum amount for the selected option. Please choose another option or change the amount.", "helloasso") @@ -122,7 +145,7 @@ public function formCheckout(Request $request, Response $response): Response $contains_donation = $contribution_type->isExtension() ? false : true; - $checkout = $helloasso->checkout($metadata, $amount * 100, $contains_donation); + $checkout = $helloasso->checkout($metadata, (float)$amount * 100, $contains_donation); if (!$checkout) { $this->flash->addMessage( @@ -134,6 +157,13 @@ public function formCheckout(Request $request, Response $response): Response ->withStatus(301) ->withHeader('Location', $this->routeparser->urlFor('helloasso_form')); } else { + //the return page will only display checkouts started here + if (isset($checkout['id'])) { + $checkouts = $this->session->helloasso_checkouts ?? []; + $checkouts[] = (string)$checkout['id']; + $this->session->helloasso_checkouts = array_slice($checkouts, -10); + } + return $response ->withStatus(301) ->withHeader('Location', $checkout['redirectUrl']); @@ -319,8 +349,9 @@ public function storePreferences(Request $request, Response $response): Response if (isset($post['helloasso_client_id'])) { $helloasso->setClientId($post['helloasso_client_id']); } - if (isset($post['helloasso_client_secret'])) { - $helloasso->setClientSecret($post['helloasso_client_secret']); + //secret is never displayed, an empty value keeps the current one + if (isset($post['helloasso_client_secret']) && trim($post['helloasso_client_secret']) !== '') { + $helloasso->setClientSecret(trim($post['helloasso_client_secret'])); } } if (isset($post['inactives'])) { @@ -501,6 +532,14 @@ public function returnUrl(Request $request, Response $response): Response throw new HttpNotFoundException($request); } + if (!in_array((string)$checkout_id, $this->session->helloasso_checkouts ?? [], true)) { + Analog::log( + 'HelloAsso checkout #' . $checkout_id . ' has not been started from this session, its details are not displayed.', + Analog::WARNING + ); + throw new HttpForbiddenException($request); + } + try { $helloasso = new Helloasso($this->zdb, $this->preferences); $tokens = $helloasso->getTokens(); diff --git a/templates/default/helloasso_history.html.twig b/templates/default/helloasso_history.html.twig index 956432a..f50803f 100644 --- a/templates/default/helloasso_history.html.twig +++ b/templates/default/helloasso_history.html.twig @@ -205,7 +205,7 @@ - {{ _T("Duplicate", "helloasso") }} + {{ _T("Duplicate entry", "helloasso") }} {% endif %} diff --git a/templates/default/helloasso_preferences.html.twig b/templates/default/helloasso_preferences.html.twig index bc162dc..3d34822 100644 --- a/templates/default/helloasso_preferences.html.twig +++ b/templates/default/helloasso_preferences.html.twig @@ -54,11 +54,14 @@ required: true } %} - {% include "components/forms/text.html.twig" with { + {% include "components/forms/input.html.twig" with { + type: 'password', id: 'helloasso_client_secret', - value: helloasso.getClientSecret(), + value: null, label: _T("Your clientSecret", "helloasso"), - required: true + required: helloasso.getClientSecret() == '', + autocomplete: 'off', + placeholder: helloasso.getClientSecret() != '' ? _T("Leave empty to keep the current one") : '' } %} {% include "components/forms/checkbox.html.twig" with { diff --git a/tests/GaletteHelloasso/Controllers/tests/units/HelloassoController.php b/tests/GaletteHelloasso/Controllers/tests/units/HelloassoController.php new file mode 100644 index 0000000..157c41d --- /dev/null +++ b/tests/GaletteHelloasso/Controllers/tests/units/HelloassoController.php @@ -0,0 +1,280 @@ + + */ +class HelloassoController extends GaletteRoutingTestCase +{ + protected int $seed = 20260928061512; + protected bool $load_plugins = true; + + /** + * Set up tests + */ + public function setUp(): void + { + parent::setUp(); + //never reach HelloAsso: any call fails at once + putenv('HTTPS_PROXY=http://127.0.0.1:1'); + } + + /** + * Cleanup after each test method + */ + public function tearDown(): void + { + putenv('HTTPS_PROXY'); + $this->login->logout(); + parent::tearDown(); + } + + /** + * Set a plugin preference + * + * @param string $name Preference name + * @param string $value Preference value + */ + private function setHelloassoPref(string $name, string $value): void + { + $update = $this->zdb->update(HELLOASSO_PREFIX . Helloasso::TABLE); + $update->set(['val_pref' => $value])->where(['nom_pref' => $name]); + $this->zdb->execute($update); + } + + /** + * Configure plugin, as an administrator would + */ + private function configure(): void + { + $this->setHelloassoPref('helloasso_organization_slug', 'galette-tests'); + $this->setHelloassoPref('helloasso_client_id', 'client-for-tests'); + $this->setHelloassoPref('helloasso_client_secret', 'secret-for-tests'); + } + + /** + * Log in given member + * + * @param array $mdata Member data + */ + private function logMember(array $mdata): void + { + $this->assertTrue($this->login->login($mdata['login_adh'], $mdata['mdp_adh'])); + } + + /** + * Set the amount of a contribution type + * + * @param int $id_type Contribution type ID + * @param float $amount Amount + */ + private function setTypeAmount(int $id_type, float $amount): void + { + $update = $this->zdb->update(ContributionsTypes::TABLE); + $update->set(['amount' => $amount])->where([ContributionsTypes::PK => $id_type]); + $this->zdb->execute($update); + } + + /** + * Post the payment form + * + * @param array $data Posted data + */ + private function postCheckout(array $data): ResponseInterface + { + $request = $this->createRequest('helloasso_formCheckout', [], 'POST')->withParsedBody($data); + return $this->app->handle($request); + } + + /** + * Assert payment form has been refused with given message, before calling HelloAsso + * + * @param ResponseInterface $test_response Response + * @param string $message Expected error message + */ + private function expectCheckoutRefused(ResponseInterface $test_response, string $message): void + { + $this->assertSame(301, $test_response->getStatusCode()); + $this->assertSame( + [$this->routeparser->urlFor('helloasso_form')], + $test_response->getHeader('Location') + ); + $this->expectFlashData(['error_detected' => [$message]]); + //a call to HelloAsso would have logged an error + $this->expectNoLogEntry(); + } + + /** + * Only payment reasons proposed to the current user can be paid + */ + public function testCheckoutRefusesUnproposedReason(): void + { + $this->configure(); + //type 1 (annual fee) is proposed, type 7 is inactive by default + $this->setTypeAmount(1, 20); + $this->setTypeAmount(7, 20); + $this->getMemberOne(); + $this->logMember($this->dataAdherentOne()); + + $this->expectCheckoutRefused( + $this->postCheckout(['item_id' => '7', 'amount' => '1']), + _T("You have to select an option.", "helloasso") + ); + $this->expectCheckoutRefused( + $this->postCheckout(['item_id' => '9999', 'amount' => '1']), + _T("You have to select an option.", "helloasso") + ); + $this->expectCheckoutRefused( + $this->postCheckout(['amount' => '20']), + _T("You have to select an option.", "helloasso") + ); + + //membership fees are not proposed to visitors + $this->login->logout(); + $this->expectCheckoutRefused( + $this->postCheckout(['item_id' => '1', 'amount' => '20']), + _T("You have to select an option.", "helloasso") + ); + } + + /** + * Amount must be a number, at least the one of the payment reason + */ + public function testCheckoutChecksAmount(): void + { + $this->configure(); + $this->setTypeAmount(5, 10); + $this->getMemberOne(); + $this->logMember($this->dataAdherentOne()); + + foreach (['', 'abc', '12abc', ['12']] as $amount) { + $this->expectCheckoutRefused( + $this->postCheckout(['item_id' => '5', 'amount' => $amount]), + _T("Please enter an amount.", "helloasso") + ); + } + foreach (['9.99', '-20', '0'] as $amount) { + $this->expectCheckoutRefused( + $this->postCheckout(['item_id' => '5', 'amount' => $amount]), + _T( + "The amount you've entered is lower than the minimum amount for the selected option. Please choose another option or change the amount.", + "helloasso" + ) + ); + } + + //decimal comma is accepted: the checkout is requested, HelloAsso cannot be reached + $test_response = $this->postCheckout(['item_id' => '5', 'amount' => '12,50']); + $this->assertSame(301, $test_response->getStatusCode()); + $this->expectFlashData(['error_detected' => [_T('An error occurred redirecting to the checkout form.', 'helloasso')]]); + $this->expectLogEntry(\Analog\Analog::ERROR, 'Error while connecting to Helloasso'); + $this->expectLogEntry(\Analog\Analog::ERROR, 'Cannot create Helloasso checkout'); + $this->expectNoLogEntry(); + } + + /** + * Get the return page of a checkout + * + * @param array $query Query parameters + */ + private function getReturnPage(array $query): ResponseInterface + { + $request = $this->createRequest('helloasso_success', [], 'GET', 'text/html', $query); + return $this->app->handle($request); + } + + /** + * Return page only displays checkouts started from the current session + */ + public function testReturnPageOnlyShowsOwnCheckouts(): void + { + $this->configure(); + + foreach ([['checkoutIntentId' => '42'], ['orderId' => '42'], ['code' => 'succeeded']] as $query) { + $test_response = $this->getReturnPage($query); + $this->assertSame(403, $test_response->getStatusCode()); + $this->expectLogEntry(\Analog\Analog::WARNING, 'has not been started from this session'); + //HelloAsso has not been called + $this->expectNoLogEntry(); + } + + //a checkout started from this session is looked for on HelloAsso + $this->session->helloasso_checkouts = ['41', '42']; + $test_response = $this->getReturnPage(['checkoutIntentId' => '42']); + $this->assertSame(403, $test_response->getStatusCode()); + $this->expectLogEntry(\Analog\Analog::ERROR, 'Error while connecting to Helloasso'); + $this->expectLogEntry(\Analog\Analog::WARNING, 'payment details could not be retrieved'); + $this->expectNoLogEntry(); + } + + /** + * Get a plugin preference, as stored + * + * @param string $name Preference name + */ + private function getHelloassoPref(string $name): string + { + $select = $this->zdb->select(HELLOASSO_PREFIX . Helloasso::TABLE); + $select->where(['nom_pref' => $name]); + return $this->zdb->execute($select)->current()->val_pref; + } + + /** + * Post preferences + * + * @param array $data Posted data + */ + private function postPreferences(array $data): void + { + $request = $this->createRequest('store_helloasso_preferences', [], 'POST')->withParsedBody( + $data + ['helloasso_organization_slug' => 'galette-tests', 'helloasso_client_id' => 'client-for-tests'] + ); + $test_response = $this->app->handle($request); + $this->assertSame(301, $test_response->getStatusCode()); + $this->expectFlashData(['success_detected' => [_T('Helloasso settings have been saved.', 'helloasso')]]); + } + + /** + * Client secret is never sent back to the browser, and kept when left empty + */ + public function testPreferencesSecret(): void + { + $this->configure(); + $this->logSuperAdmin(); + + $test_response = $this->app->handle($this->createRequest('helloasso_preferences')); + $this->assertSame(200, $test_response->getStatusCode()); + //organization cannot be retrieved from HelloAsso + $this->expectLogEntry(\Analog\Analog::ERROR, 'Error while connecting to Helloasso'); + $this->expectLogEntry(\Analog\Analog::ERROR, 'Exception when calling OrganisationApi'); + $this->expectNoLogEntry(); + $body = (string)$test_response->getBody(); + $this->assertStringContainsString('client-for-tests', $body); + $this->assertStringNotContainsString('secret-for-tests', $body); + $this->assertMatchesRegularExpression('/postPreferences(['helloasso_client_secret' => ' ']); + $this->assertSame('secret-for-tests', $this->getHelloassoPref('helloasso_client_secret')); + + $this->postPreferences(['helloasso_client_secret' => 'new-secret']); + $this->assertSame('new-secret', $this->getHelloassoPref('helloasso_client_secret')); + $this->expectNoLogEntry(); + } +}