Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 7 additions & 15 deletions server/src/Support/QPay.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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);
Expand Down
54 changes: 30 additions & 24 deletions server/tests/Unit/Support/QPayTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down
Loading