diff --git a/server/src/Support/QPay.php b/server/src/Support/QPay.php index fb6b815..fb3e017 100644 --- a/server/src/Support/QPay.php +++ b/server/src/Support/QPay.php @@ -8,7 +8,6 @@ use Fleetbase\Storefront\Models\Product; use Fleetbase\Support\Utils; use GuzzleHttp\Client; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; /** @@ -353,20 +352,13 @@ public function setAuthToken(?string $accessToken = null): QPay return $this; } - $username = $this->requestOptions['auth'][0] ?? ''; - $cacheKey = 'storefront:qpay:token:' . md5(($this->requestOptions['base_uri'] ?? '') . '|' . $username); - $token = Cache::get($cacheKey); - - if (!$token) { - $response = $this->getAuthToken(); - $token = data_get($response, 'access_token'); - $expiresIn = (int) data_get($response, 'expires_in', 0); - - // Reuse the token across requests until shortly before it expires - if ($token && $expiresIn > 120) { - Cache::put($cacheKey, $token, $expiresIn - 60); - } - } + // Always mint a fresh token. QPay returns `expires_in` as an absolute UNIX + // timestamp, not a lifetime in seconds, so caching keyed off it cached the token + // for decades and every call kept sending a token QPay had already expired — + // answering NO_CREDENTIALS with perfectly valid credentials. Reuse is worth one + // round trip only if the real expiry is honoured; until then, correctness wins. + $response = $this->getAuthToken(); + $token = data_get($response, 'access_token'); if ($token) { $this->useBearerToken($token); diff --git a/server/tests/Unit/Support/QPayTest.php b/server/tests/Unit/Support/QPayTest.php index 21d90dd..0fe50a9 100644 --- a/server/tests/Unit/Support/QPayTest.php +++ b/server/tests/Unit/Support/QPayTest.php @@ -125,36 +125,42 @@ function qpayWithResponses(array $responses, array &$history): QPay ->and((string) $history[2]['request']->getUri())->toContain('payment/payment-7'); }); -test('qpay caches auth tokens across instances until shortly before expiry', function () { +test('qpay mints a fresh auth token for every authentication', function () { + // QPay returns `expires_in` as an ABSOLUTE UNIX TIMESTAMP, not a lifetime in + // seconds — the value below is a real one, and it is roughly a day away, not the + // 56 years it would mean as a duration. Caching a token against that reading kept + // sending a token QPay had already expired, so authentication must not reuse one. $history = []; - $mock = new MockHandler([ - new Response(200, [], '{"access_token":"cached-token","expires_in":3600}'), - ]); - $handler = HandlerStack::create($mock); - $handler->push(Middleware::history($history)); + $qpay = qpayWithResponses([ + new Response(200, [], '{"access_token":"first-token","expires_in":1788603222}'), + new Response(200, [], '{"ok":true}'), + new Response(200, [], '{"access_token":"second-token","expires_in":1788603222}'), + new Response(200, [], '{"ok":true}'), + ], $history); - $qpay = new QPay('cache-merchant', 'cache-secret', 'https://storefront.test/qpay'); - $qpay->updateRequestOption('handler', $handler); + $qpay->setAuthToken(); + $qpay->get('health'); + $qpay->setAuthToken(); + $qpay->get('health'); - expect($qpay->setAuthToken())->toBe($qpay); + expect($history)->toHaveCount(4) + ->and((string) $history[0]['request']->getUri())->toContain('auth/token') + ->and($history[1]['request']->getHeaderLine('Authorization'))->toBe('Bearer first-token') + ->and((string) $history[2]['request']->getUri())->toContain('auth/token') + ->and($history[3]['request']->getHeaderLine('Authorization'))->toBe('Bearer second-token'); +}); - // A new instance with the same credentials reuses the cached token without re-authenticating - $secondHistory = []; - $secondMock = new MockHandler([ +test('qpay authentication tolerates a token response without an access token', function () { + $history = []; + $qpay = qpayWithResponses([ + new Response(200, [], '{"error":"NO_CREDENTIALS"}'), new Response(200, [], '{"ok":true}'), - ]); - $secondHandler = HandlerStack::create($secondMock); - $secondHandler->push(Middleware::history($secondHistory)); - - $second = new QPay('cache-merchant', 'cache-secret', 'https://storefront.test/qpay'); - $second->updateRequestOption('handler', $secondHandler); + ], $history); - expect($second->setAuthToken())->toBe($second) - ->and($second->get('health')->ok)->toBeTrue() - ->and($history)->toHaveCount(1) - ->and((string) $history[0]['request']->getUri())->toContain('auth/token') - ->and($secondHistory)->toHaveCount(1) - ->and($secondHistory[0]['request']->getHeaderLine('Authorization'))->toBe('Bearer cached-token'); + expect($qpay->setAuthToken())->toBe($qpay) + ->and($qpay->get('health')->ok)->toBeTrue() + // No bearer is installed, so the client keeps falling back to basic auth + ->and($history[1]['request']->getHeaderLine('Authorization'))->not->toContain('Bearer'); }); test('qpay invoice factory authenticates and forwards invoice parameters', function () {