All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 2m22s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m34s
Every quote waited for an operator before it could be paid, so an order placed at night waited for the morning. A cart the Site priced is now approved when the quote is created, through the same server pricing the operator's approval uses (app/quote_review.py). Orders above QUOTE_AUTO_MAX_METRES (50 m) and items claiming a discount on art the Site could not analyse still wait for review; the Kanban shows which quotes were approved automatically and why the others wait. The grade is still computed in the browser (roadmap 3.2, 3.9), so the discount remains a customer-supplied value until the server computes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
130 lines
6.7 KiB
Python
130 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, approved_quote, 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 = approved_quote(customer, quote, [item])
|
|
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/events?order=' + str(order['number']), 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()
|