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();
+ }
+}