fix(security): unique payment reference_id, reject replayed callbacks (H3)
reference_id (the gateway's settled-transaction ref) was not unique, so the same successful callback — or a RefNum replayed onto another order — could credit twice. Add a unique index (NULL until success, so pending/failed rows don't collide) and an application-level pre-check in the callback that fails the payment if the reference already belongs to another order. The unique index is the hard backstop behind the check. Regression: PaymentCallbackAmountTest::testReplayedGatewayReferenceIsRejected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -71,4 +71,32 @@ class PaymentCallbackAmountTest extends ApiTestCase
|
||||
|
||||
$this->assertSame(Payment::STATUS_SUCCESS, $this->reload($payment)->getStatus());
|
||||
}
|
||||
|
||||
public function testReplayedGatewayReferenceIsRejected(): void
|
||||
{
|
||||
$this->enableTestMode();
|
||||
|
||||
// Unique per run — db_test is shared and not reset between runs.
|
||||
$ref = 'REF-' . bin2hex(random_bytes(8));
|
||||
|
||||
$first = $this->makePayment(50000);
|
||||
$this->fireCallbackWithRef($first, $ref, 50000);
|
||||
$this->assertSame(Payment::STATUS_SUCCESS, $this->reload($first)->getStatus());
|
||||
|
||||
// Same gateway reference replayed onto a different (same-amount) order.
|
||||
$second = $this->makePayment(50000);
|
||||
$this->fireCallbackWithRef($second, $ref, 50000);
|
||||
$this->assertSame(Payment::STATUS_FAILED, $this->reload($second)->getStatus());
|
||||
}
|
||||
|
||||
private function fireCallbackWithRef(Payment $payment, string $refId, int $reportedAmount): void
|
||||
{
|
||||
$this->client->request('POST', '/api/v1/payment/callback/mock?' . http_build_query([
|
||||
'order_id' => $payment->getOrderId(),
|
||||
'mock' => '1',
|
||||
'ResCode' => '0',
|
||||
'RefId' => $refId,
|
||||
'mock_amount' => (string) $reportedAmount,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user