1<?php
2
3declare(strict_types=1);
4
5namespace ZnoteX\Tests\Security;
6
7use PHPUnit\Framework\TestCase;
8
9final class PaymentWebhookTest extends TestCase
10{
11 private const SECRET = 'whsec_test_only_1234567890';
12
13 protected function setUp(): void
14 {
15 $GLOBALS['config'] = [
16 'stripe' => ['webhook_secret' => self::SECRET],
17 'mercadopago' => ['webhook_secret' => self::SECRET],
18 ];
19 }
20
21 public function testGenuineStripeSignatureIsAccepted(): void
22 {
23 $payload = '{"id":"evt_1","type":"checkout.session.completed"}';
24 $header = $this->realStripeHeader($payload);
25
26 $this->assertTrue(\payment_gateway_verify_stripe_signature($payload, $header));
27 }
28
29 public function testForgedStripeSignatureIsRejected(): void
30 {
31 $payload = '{"id":"evt_1","type":"checkout.session.completed"}';
32 $timestamp = time();
33 $forgedSignature = hash_hmac('sha256', $timestamp . '.' . $payload, 'attacker-does-not-know-the-real-secret');
34 $header = "t={$timestamp},v1={$forgedSignature}";
35
36 $this->assertFalse(\payment_gateway_verify_stripe_signature($payload, $header));
37 }
38
39 public function testTamperedPayloadInvalidatesAGenuineSignature(): void
40 {
41 $originalPayload = '{"id":"evt_1","amount_total":100}';
42 $header = $this->realStripeHeader($originalPayload);
43
44 $tamperedPayload = '{"id":"evt_1","amount_total":999999}';
45 $this->assertFalse(\payment_gateway_verify_stripe_signature($tamperedPayload, $header));
46 }
47
48 public function testReplayedOldSignatureIsRejected(): void
49 {
50 $payload = '{"id":"evt_1"}';
51 $oldTimestamp = time() - 3600;
52 $signature = hash_hmac('sha256', $oldTimestamp . '.' . $payload, self::SECRET);
53 $header = "t={$oldTimestamp},v1={$signature}";
54
55 $this->assertFalse(\payment_gateway_verify_stripe_signature($payload, $header));
56 }
57
58 public function testMissingHeaderIsRejected(): void
59 {
60 $this->assertFalse(\payment_gateway_verify_stripe_signature('{}', ''));
61 }
62
63 public function testEmptyConfiguredSecretRejectsEverything(): void
64 {
65 $GLOBALS['config']['stripe']['webhook_secret'] = '';
66 $payload = '{"id":"evt_1"}';
67 $header = $this->realStripeHeader($payload, 'whatever');
68
69 $this->assertFalse(\payment_gateway_verify_stripe_signature($payload, $header));
70 }
71
72 public function testGenuineMercadopagoSignatureIsAccepted(): void
73 {
74 $dataId = '123456';
75 $requestId = 'req-1';
76 $header = $this->realMercadopagoHeader($dataId, $requestId, $timestamp = (string)time());
77
78 $this->assertTrue(\payment_gateway_verify_mercadopago_signature($dataId, $requestId, $header));
79 }
80
81 public function testForgedMercadopagoSignatureIsRejected(): void
82 {
83 $dataId = '123456';
84 $requestId = 'req-1';
85 $timestamp = (string)time();
86 $manifest = 'id:' . $dataId . ';request-id:' . $requestId . ';ts:' . $timestamp . ';';
87 $forged = hash_hmac('sha256', $manifest, 'not-the-real-secret');
88 $header = "ts={$timestamp},v1={$forged}";
89
90 $this->assertFalse(\payment_gateway_verify_mercadopago_signature($dataId, $requestId, $header));
91 }
92
93 public function testMercadopagoSignatureBoundToTheWrongDataIdIsRejected(): void
94 {
95 $requestId = 'req-1';
96 $timestamp = (string)time();
97 $header = $this->realMercadopagoHeader('legit-payment-id', $requestId, $timestamp);
98
99 // An attacker who intercepted a genuine notification for one payment
100 // cannot replay it against a different data id.
101 $this->assertFalse(\payment_gateway_verify_mercadopago_signature('someone-elses-payment-id', $requestId, $header));
102 }
103
104 public function testAlreadyCreditedTransactionIsNeverCreditedTwice(): void
105 {
106 $tx = ['credited' => 1, 'account_id' => 5, 'points' => 100, 'price' => 9.99, 'currency' => 'USD'];
107 $this->assertSame('already_credited', \payment_gateway_validate_transaction('stripe', $tx, '', []));
108 }
109
110 public function testMissingTransactionIsRejected(): void
111 {
112 $this->assertSame('missing_transaction', \payment_gateway_validate_transaction('stripe', false, '', []));
113 }
114
115 public function testProviderReferenceCannotBeSwappedAfterTheFactForStripe(): void
116 {
117 $tx = [
118 'credited' => 0,
119 'provider_reference' => 'pi_original_genuine_payment_intent',
120 'account_id' => 5,
121 'points' => 100,
122 'price' => 9.99,
123 'currency' => 'USD',
124 ];
125
126 $result = \payment_gateway_validate_transaction('stripe', $tx, 'pi_attacker_supplied_different_intent', []);
127 $this->assertSame('provider_reference_mismatch', $result);
128 }
129
130 public function testInvalidAccountOrPointsIsRejected(): void
131 {
132 $tx = ['credited' => 0, 'account_id' => 0, 'points' => 100, 'price' => 9.99, 'currency' => 'USD'];
133 $this->assertSame('invalid_transaction', \payment_gateway_validate_transaction('mercadopago', $tx, '', []));
134
135 $tx['account_id'] = 5;
136 $tx['points'] = 0;
137 $this->assertSame('invalid_transaction', \payment_gateway_validate_transaction('mercadopago', $tx, '', []));
138 }
139
140 public function testAmountMismatchBlocksCrediting(): void
141 {
142 $tx = ['credited' => 0, 'account_id' => 5, 'points' => 100, 'price' => 9.99, 'currency' => 'USD'];
143 $payload = ['transaction_amount' => 0.01, 'currency_id' => 'usd'];
144
145 $this->assertSame('amount_mismatch', \payment_gateway_validate_transaction('mercadopago', $tx, '', $payload));
146 }
147
148 public function testLiveModeCannotCreditATestModeTransactionOrViceVersa(): void
149 {
150 $tx = ['credited' => 0, 'account_id' => 5, 'points' => 100, 'price' => 9.99, 'currency' => 'USD', 'test_mode' => 1];
151 $payload = ['transaction_amount' => 9.99, 'currency_id' => 'usd', 'live_mode' => true];
152
153 $this->assertSame('mode_mismatch', \payment_gateway_validate_transaction('mercadopago', $tx, '', $payload));
154 }
155
156 public function testAFullyValidTransactionPassesValidation(): void
157 {
158 $tx = ['credited' => 0, 'account_id' => 5, 'points' => 100, 'price' => 9.99, 'currency' => 'USD', 'test_mode' => 0];
159 $payload = ['transaction_amount' => 9.99, 'currency_id' => 'usd', 'live_mode' => true];
160
161 $this->assertSame('ok', \payment_gateway_validate_transaction('mercadopago', $tx, '', $payload));
162 }
163
164 private function realStripeHeader(string $payload, ?string $secret = null): string
165 {
166 $timestamp = time();
167 $signature = hash_hmac('sha256', $timestamp . '.' . $payload, $secret ?? self::SECRET);
168 return "t={$timestamp},v1={$signature}";
169 }
170
171 private function realMercadopagoHeader(string $dataId, string $requestId, string $timestamp): string
172 {
173 $manifest = 'id:' . $dataId . ';request-id:' . $requestId . ';ts:' . $timestamp . ';';
174 $signature = hash_hmac('sha256', $manifest, self::SECRET);
175 return "ts={$timestamp},v1={$signature}";
176 }
177}
178