All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m23s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
PDF artwork: a single-page PDF source is placed in the print file as a vector form through pikepdf, never rasterised, using the CropBox and inherited /Rotate the Site measured with pdf.js. Multi-page and protected PDFs go to hand preparation. PyMuPDF was not used because of its AGPL licence. Raster tests cover crop, page rotation, placement rotation and mirroring, and fail when the rotation or crop handling is broken. Card payment: Mercado Pago's Card Payment Brick on the Site when MP_PUBLIC_KEY is set; the card becomes a one-time token in Mercado Pago's secure fields. Each card attempt has its own idempotency key, and the intent route refuses new attempts once a payment is approved or a card is in review, so a quote cannot be charged twice. The Site CSP admits Mercado Pago's origins only through PAYMENT_CSP_SOURCES, empty by default. Logins: every attempt counts against the source address, only failures against the account. Counting successful sign-ins let ordinary use lock an operator out and made CI's final browser sign-in fail. No new required settings; production behaviour is unchanged until the provider credentials are configured. Verified with the full CI integration sequence locally. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
131 lines
6.7 KiB
Python
131 lines
6.7 KiB
Python
"""The webhook path, against a running stack.
|
|
|
|
A provider retries. It delivers out of order, twice, and late. None of that may
|
|
produce a second order or a second notification to the customer, and nothing
|
|
unsigned may produce one at all.
|
|
"""
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
from urllib.error import HTTPError
|
|
from urllib.request import Request, urlopen
|
|
from uuid import uuid4
|
|
|
|
from tests.smoke_test import BASE, Client, upload_bytes, item_spec, with_host
|
|
|
|
SECRET = os.environ.get('PAYMENT_WEBHOOK_SECRET', 'local-webhook-secret').encode()
|
|
|
|
|
|
def deliver(payload, expected=200, signature=None):
|
|
body = json.dumps(payload).encode()
|
|
sig = signature if signature is not None else hmac.new(SECRET, body, hashlib.sha256).hexdigest()
|
|
request = Request(BASE + '/api/payments/webhook', data=body,
|
|
headers=with_host({'Content-Type': 'application/json',
|
|
'x-payment-signature': sig}))
|
|
try:
|
|
with urlopen(request, timeout=30) as response:
|
|
assert response.status == expected, (response.status, expected)
|
|
return json.load(response)
|
|
except HTTPError as exc:
|
|
assert exc.code == expected, (exc.code, expected, exc.read().decode())
|
|
return {}
|
|
|
|
|
|
def reviewed_quote():
|
|
"""A quote an operator has approved, ready to be paid."""
|
|
customer = Client()
|
|
customer.call('/session')
|
|
uid = upload_bytes(customer, b'PAYMENT WEBHOOK TEST')
|
|
item = item_spec('file', '1.01', 0, uid)
|
|
profile = {'cnpj': '11222333000181', 'zap': '11999999999',
|
|
'mail': 'payment-' + uuid4().hex[:8] + '@example.test'}
|
|
quote = customer.call('/quotes', {'request_key': str(uuid4()), 'customer': profile,
|
|
'items': [item], 'freight': {'service': 'pickup'}})
|
|
approved = customer.call('/operator/quotes/' + quote['id'] + '/approve',
|
|
{'items': [item]}, operator=True)
|
|
return customer, quote['id'], approved['total_cents']
|
|
|
|
|
|
def run():
|
|
customer, quote_id, total = reviewed_quote()
|
|
|
|
# Nothing unsigned creates an order, and a tampered body is not signed.
|
|
deliver({'event_id': 'unsigned-' + uuid4().hex, 'reference': quote_id,
|
|
'status': 'approved', 'amount_cents': total}, expected=403, signature='')
|
|
deliver({'event_id': 'tampered-' + uuid4().hex, 'reference': quote_id,
|
|
'status': 'approved', 'amount_cents': total}, expected=403, signature='0' * 64)
|
|
assert not customer.call('/quotes/' + quote_id)['order'], 'unsigned delivery created an order'
|
|
print('PASS: unsigned and tampered deliveries are refused and create nothing')
|
|
|
|
# Starting a PIX twice returns the same one. A card in review blocks every
|
|
# further attempt, so one quote can never be charged twice.
|
|
pix = customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})
|
|
assert customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})['id'] == pix['id']
|
|
customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'card'}}, expected=422)
|
|
card = {'type': 'card', 'token': 'tok-1', 'payment_method_id': 'visa', 'installments': 1}
|
|
customer.call('/payments/intent', {'quote_id': quote_id, 'method': card})
|
|
customer.call('/payments/intent', {'quote_id': quote_id, 'method': {**card, 'token': 'tok-2'}}, expected=409)
|
|
customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}}, expected=409)
|
|
stranger = Client()
|
|
stranger.call('/session')
|
|
stranger.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}}, expected=404)
|
|
print('PASS: payment start is idempotent for PIX and refuses a second charge')
|
|
|
|
# An approved payment for the wrong amount must not become an order.
|
|
deliver({'event_id': 'short-' + uuid4().hex, 'reference': quote_id,
|
|
'status': 'approved', 'amount_cents': total - 100})
|
|
assert not customer.call('/quotes/' + quote_id)['order'], 'underpayment created an order'
|
|
deliver({'event_id': 'missing-amount-' + uuid4().hex, 'reference': quote_id,
|
|
'status': 'approved'})
|
|
assert not customer.call('/quotes/' + quote_id)['order'], 'missing paid amount created an order'
|
|
deliver({'event_id': 'invalid-amount-' + uuid4().hex, 'reference': quote_id,
|
|
'status': 'approved', 'amount_cents': str(total)})
|
|
assert not customer.call('/quotes/' + quote_id)['order'], 'non-integer paid amount created an order'
|
|
print('PASS: a missing, invalid or mismatched paid amount is refused')
|
|
|
|
# The real thing, then the same delivery again, and a second event for the
|
|
# same quote: a provider does all three.
|
|
event = 'paid-' + uuid4().hex
|
|
payload = {'event_id': event, 'reference': quote_id, 'status': 'approved',
|
|
'amount_cents': total}
|
|
first = deliver(payload)
|
|
assert first['status'] == 'applied', first
|
|
order = customer.call('/quotes/' + quote_id)['order']
|
|
assert order, 'approved payment did not create an order'
|
|
|
|
again = deliver(payload)
|
|
assert again['status'] == 'duplicate', again
|
|
later = deliver({**payload, 'event_id': 'retry-' + uuid4().hex})
|
|
assert 'already existed' in later.get('outcome', ''), later
|
|
assert customer.call('/quotes/' + quote_id)['order']['id'] == order['id'], 'a second order appeared'
|
|
print('PASS: one order from a repeated and re-sent approval')
|
|
|
|
assert customer.call('/orders/dev-paid', {'quote_id': quote_id})['id'] == order['id']
|
|
other = Client()
|
|
other.call('/session')
|
|
other.call('/orders/dev-paid', {'quote_id': quote_id}, expected=404)
|
|
print('PASS: another customer cannot retrieve the paid order by quote id')
|
|
|
|
# The customer is told once, not once per delivery.
|
|
board = Client()
|
|
events = board.call('/operator/board', operator=True)['events']
|
|
paid = [e for e in events if e['payload'].get('order_id') == order['id']
|
|
and e['payload'].get('event') == 'payment_approved']
|
|
assert len(paid) == 2, f'expected one tiny and one whatsapp event, got {len(paid)}'
|
|
assert {e['provider'] for e in paid} == {'tiny', 'whatsapp'}, paid
|
|
print('PASS: exactly one notification per provider for the order')
|
|
|
|
# A payment that was never reviewed, and one for something that is not a quote.
|
|
deliver({'event_id': 'nonsense-' + uuid4().hex, 'reference': 'not-a-uuid',
|
|
'status': 'approved', 'amount_cents': 100})
|
|
deliver({'event_id': 'missing-' + uuid4().hex, 'reference': str(uuid4()),
|
|
'status': 'approved', 'amount_cents': 100})
|
|
deliver({'event_id': 'pending-' + uuid4().hex, 'reference': quote_id,
|
|
'status': 'pending', 'amount_cents': total})
|
|
print('PASS: unknown references and non-approved statuses are recorded without acting')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
run()
|