All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m28s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 47s
Without an approved card payment there was no way to try the board, the files and the print flow in production. "Criar pedido de teste" on an approved quote creates the order through the same path as a paid one, marked TESTE on its card and panel and audited; neither it nor its stage moves queue anything for Tiny or WhatsApp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
161 lines
8.8 KiB
Python
161 lines
8.8 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 app.core import db
|
|
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 pix['expires_at']
|
|
assert customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})['id'] == pix['id']
|
|
# Once the code has expired, asking again opens a new one, and only one.
|
|
with db.connect() as c:
|
|
c.execute('''UPDATE dtf_local.payment_intents
|
|
SET response=jsonb_set(response,'{expires_at}',to_jsonb((now()-interval '1 minute')::text))
|
|
WHERE provider_payment_id=%s''', (pix['id'],))
|
|
renewed = customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})
|
|
assert renewed['id'] != pix['id'], 'an expired PIX was offered again'
|
|
assert customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})['id'] == renewed['id']
|
|
with db.connect() as c:
|
|
statuses = {r['provider_payment_id']: r['status'] for r in c.execute(
|
|
"SELECT provider_payment_id,status FROM dtf_local.payment_intents WHERE quote_id=%s AND method='pix'",
|
|
(quote_id,)).fetchall()}
|
|
assert statuses == {pix['id']: 'expired', renewed['id']: 'pending'}, statuses
|
|
print('PASS: a PIX code expires after 30 minutes and is replaced by exactly one new code')
|
|
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')
|
|
|
|
# An operator's test order runs the production flow and notifies no one.
|
|
tester, test_quote, _ = reviewed_quote()
|
|
order = tester.call('/operator/quotes/' + test_quote + '/test-order', {}, operator=True)
|
|
assert order['payment']['provider'] == 'teste' and order['state'] == 'rec', order
|
|
assert tester.call('/operator/quotes/' + test_quote + '/test-order', {}, operator=True)['id'] == order['id']
|
|
version = order['version']
|
|
for state in ('tra',):
|
|
moved = tester.call('/operator/orders/' + order['id'] + '/move', {'state': state, 'version': version}, operator=True)
|
|
version = moved['version']
|
|
with db.connect() as c:
|
|
queued = c.execute("SELECT count(*) AS n FROM dtf_local.outbox WHERE event_key LIKE %s", (order['id'] + ':%',)).fetchone()['n']
|
|
jobs = c.execute('SELECT count(*) AS n FROM dtf_local.print_files WHERE order_id=%s', (order['id'],)).fetchone()['n']
|
|
assert queued == 0 and jobs == 1, (queued, jobs)
|
|
print('PASS: an operator test order reaches the board and the print queue, never Tiny or WhatsApp')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
run()
|