From 2f88f819885f6fec262d7b9fe637fdf8c0148da1 Mon Sep 17 00:00:00 2001 From: martian56 Date: Mon, 20 Jul 2026 17:41:50 +0400 Subject: [PATCH 1/3] Refuse requests missing a documented-required field An audit of all 30 endpoints against developer.epoint.az found 16 places where the sandbox accepted a payload production documents as invalid. language was defaulted to az on twelve endpoints, and display and save_as_template were defaulted on invoice create and update. This is the dangerous direction. The developer builds against a sandbox that accepts their incomplete request and only finds out on the switch to production. token/widget keeps its old behaviour: its doc page lists only public_key, amount, order_id and description. --- apps/api/src/epoint_sandbox/api/cards.py | 6 ++++-- apps/api/src/epoint_sandbox/api/extras.py | 3 +++ apps/api/src/epoint_sandbox/api/invoices.py | 20 ++++++++++++------- apps/api/src/epoint_sandbox/api/money.py | 6 +++--- .../src/epoint_sandbox/services/payments.py | 4 ++++ 5 files changed, 27 insertions(+), 12 deletions(-) diff --git a/apps/api/src/epoint_sandbox/api/cards.py b/apps/api/src/epoint_sandbox/api/cards.py index e8638e4..e8335fe 100644 --- a/apps/api/src/epoint_sandbox/api/cards.py +++ b/apps/api/src/epoint_sandbox/api/cards.py @@ -176,7 +176,7 @@ async def execute_pay( signed: SignedRequestDep, session: SessionDep, ) -> dict[str, Any]: - signed.require("card_id", "order_id", "amount", "currency") + signed.require("language", "card_id", "order_id", "amount", "currency") card = _load_card(session, signed) trace_id = _trace(request) transaction = _charge_saved_card( @@ -191,7 +191,9 @@ async def split_execute_pay( signed: SignedRequestDep, session: SessionDep, ) -> dict[str, Any]: - signed.require("card_id", "order_id", "amount", "currency", "split_user", "split_amount") + signed.require( + "language", "card_id", "order_id", "amount", "currency", "split_user", "split_amount" + ) card = _load_card(session, signed) trace_id = _trace(request) diff --git a/apps/api/src/epoint_sandbox/api/extras.py b/apps/api/src/epoint_sandbox/api/extras.py index c401f81..a98be19 100644 --- a/apps/api/src/epoint_sandbox/api/extras.py +++ b/apps/api/src/epoint_sandbox/api/extras.py @@ -68,6 +68,7 @@ async def installment_request( "status": "success", "redirect_url": payments.checkout_url(transaction), "transaction": transaction.transaction_id, + "message": "", "trace_id": trace_id, } @@ -99,6 +100,7 @@ async def wallet_payment( "status": "success", "redirect_url": payments.checkout_url(transaction), "transaction": transaction.transaction_id, + "message": "", "trace_id": trace_id, } @@ -118,6 +120,7 @@ async def token_widget( trace_id=trace_id, allowed_currencies=AZN_ONLY, default_currency="AZN", + require_language=False, ) base = get_settings().public_base_url return { diff --git a/apps/api/src/epoint_sandbox/api/invoices.py b/apps/api/src/epoint_sandbox/api/invoices.py index 19f5aa4..b6667a4 100644 --- a/apps/api/src/epoint_sandbox/api/invoices.py +++ b/apps/api/src/epoint_sandbox/api/invoices.py @@ -76,19 +76,19 @@ def _serialise(invoice: Invoice) -> dict[str, Any]: @router.post("/create") async def create(request: Request, signed: SignedRequestDep, session: SessionDep) -> dict[str, Any]: - signed.require("sum", "period_from", "period_to") + signed.require("sum", "display", "save_as_template", "period_from", "period_to") invoice = Invoice(merchant_id=signed.merchant.id, total=0) _apply(invoice, signed) session.add(invoice) session.flush() - return {"status": "success", "id": invoice.id, "trace_id": _trace(request)} + return {"status": "success", "id": invoice.id, "message": "", "trace_id": _trace(request)} @router.post("/update") async def update(request: Request, signed: SignedRequestDep, session: SessionDep) -> dict[str, Any]: - signed.require("sum", "period_from", "period_to") + signed.require("sum", "display", "save_as_template", "period_from", "period_to") invoice = _load(session, signed) if invoice.status is not InvoiceStatus.WAITING: @@ -96,13 +96,18 @@ async def update(request: Request, signed: SignedRequestDep, session: SessionDep _apply(invoice, signed) session.flush() - return {"status": "success", "trace_id": _trace(request)} + return {"status": "success", "message": "", "trace_id": _trace(request)} @router.post("/view") async def view(request: Request, signed: SignedRequestDep, session: SessionDep) -> dict[str, Any]: invoice = _load(session, signed) - return {"status": "success", "invoice": _serialise(invoice), "trace_id": _trace(request)} + return { + "status": "success", + "invoice": _serialise(invoice), + "message": "", + "trace_id": _trace(request), + } @router.post("/list") @@ -124,6 +129,7 @@ async def list_invoices( return { "status": "success", "invoices": [_serialise(i) for i in rows], + "message": "", "trace_id": _trace(request), } @@ -141,7 +147,7 @@ async def send_sms( recipient=str(signed.get("phone")), trace_id=_trace(request), ) - return {"status": "success", "trace_id": _trace(request)} + return {"status": "success", "message": "", "trace_id": _trace(request)} @router.post("/send-email") @@ -157,4 +163,4 @@ async def send_email( recipient=str(signed.get("email")), trace_id=_trace(request), ) - return {"status": "success", "trace_id": _trace(request)} + return {"status": "success", "message": "", "trace_id": _trace(request)} diff --git a/apps/api/src/epoint_sandbox/api/money.py b/apps/api/src/epoint_sandbox/api/money.py index 0cb316b..5a98976 100644 --- a/apps/api/src/epoint_sandbox/api/money.py +++ b/apps/api/src/epoint_sandbox/api/money.py @@ -30,7 +30,7 @@ async def refund_request( signed: SignedRequestDep, session: SessionDep, ) -> dict[str, Any]: - signed.require("card_id", "order_id", "amount", "currency") + signed.require("language", "card_id", "order_id", "amount", "currency") trace_id = _trace(request) card = session.scalar( @@ -108,7 +108,7 @@ async def reverse( signed: SignedRequestDep, session: SessionDep, ) -> dict[str, Any]: - signed.require("transaction", "currency") + signed.require("language", "transaction", "currency") trace_id = _trace(request) payments.validate_currency(str(signed.get("currency")), AZN_ONLY) @@ -144,7 +144,7 @@ async def reverse( ) callbacks.deliver(session, original) - return {"status": "success", "trace_id": trace_id} + return {"status": "success", "message": "", "trace_id": trace_id} @router.post("/pre-auth-complete") diff --git a/apps/api/src/epoint_sandbox/services/payments.py b/apps/api/src/epoint_sandbox/services/payments.py index ed89b1d..2a1b958 100644 --- a/apps/api/src/epoint_sandbox/services/payments.py +++ b/apps/api/src/epoint_sandbox/services/payments.py @@ -40,8 +40,12 @@ def create_transaction( trace_id: str, allowed_currencies: set[str] = SUPPORTED_CURRENCIES, default_currency: str | None = None, + require_language: bool = True, ) -> Transaction: required = ["amount", "order_id"] if default_currency else ["amount", "currency", "order_id"] + # Documented required everywhere except token/widget, so refuse rather than default it. + if require_language: + required.append("language") request.require(*required) amount = parse_amount(request.get("amount")) From 0c668ab0163fd0fa2ddf00e6f880a05b437277a0 Mon Sep 17 00:00:00 2001 From: martian56 Date: Mon, 20 Jul 2026 17:41:51 +0400 Subject: [PATCH 2/3] Return the documented message field Fifteen endpoints document message and none of them returned it, so an integration reading it got undefined here and a string in production. The production wording is unknown, so an empty string carries the shape and the gap is recorded as unverified. --- apps/api/src/epoint_sandbox/api/contract.py | 10 ++++++++++ apps/api/src/epoint_sandbox/api/epoint.py | 2 ++ scripts/extract_contracts.py | 9 +++++++++ 3 files changed, 21 insertions(+) diff --git a/apps/api/src/epoint_sandbox/api/contract.py b/apps/api/src/epoint_sandbox/api/contract.py index 5ada779..656bf05 100644 --- a/apps/api/src/epoint_sandbox/api/contract.py +++ b/apps/api/src/epoint_sandbox/api/contract.py @@ -154,6 +154,12 @@ "/api/1/b2b/payment": "not exercised against production", "/api/1/b2b/payment/{order_id}": "not exercised against production", "/api/1/card-registration": "redirect_url is undocumented; confirm against production", + "/api/1/card-registration-with-pay": ( + "the documented response carries rrn, bank_response and operation_code 200, " + "which only exist once the customer has paid, so it reads as the callback " + "payload rather than the response; those fields are withheld until a " + "production capture settles it" + ), "/api/1/payment-change-sum": ( "docs omit transaction where siblings include it; the sandbox withholds it as " "the safe direction, but production may send it" @@ -163,4 +169,8 @@ "the sandbox accepts any language value; whether production validates it " "against az, en and ru is unknown" ), + "/api/1/reverse": ( + "message is documented but the production wording is unknown, so an empty " + "string is returned to match the shape" + ), } diff --git a/apps/api/src/epoint_sandbox/api/epoint.py b/apps/api/src/epoint_sandbox/api/epoint.py index ff1f3b8..a739307 100644 --- a/apps/api/src/epoint_sandbox/api/epoint.py +++ b/apps/api/src/epoint_sandbox/api/epoint.py @@ -23,6 +23,8 @@ def _redirect_response( body: dict[str, Any] = { "status": "success", "redirect_url": payments.checkout_url(transaction), + # Documented on every one of these. The production wording is unknown. + "message": "", "trace_id": trace_id, } if include_transaction: diff --git a/scripts/extract_contracts.py b/scripts/extract_contracts.py index 678ca2d..ac283f7 100644 --- a/scripts/extract_contracts.py +++ b/scripts/extract_contracts.py @@ -62,6 +62,15 @@ UNVERIFIED = { "/api/1/card-registration": "redirect_url is undocumented; confirm against production", + "/api/1/card-registration-with-pay": ( + "the documented response carries rrn, bank_response and operation_code 200, which " + "only exist once the customer has paid, so it reads as the callback payload rather " + "than the response; those fields are withheld until a production capture settles it" + ), + "/api/1/reverse": ( + "message is documented but the production wording is unknown, so an empty string " + "is returned to match the shape" + ), "/api/1/payment-change-sum": ( "docs omit transaction where siblings include it; the sandbox withholds it " "as the safe direction, but production may send it" From 5fd3ac584ade3749191241a3e3ebae5e2c519a66 Mon Sep 17 00:00:00 2001 From: martian56 Date: Mon, 20 Jul 2026 17:41:52 +0400 Subject: [PATCH 3/3] Guard both directions of the response contract The contract test only checked for fields the sandbox invents, never for documented fields it omits, so the message gap went unnoticed. Adds the missing direction, plus 63 cases asserting every documented-required field is enforced. --- apps/api/tests/test_contract.py | 123 ++++++++++++++ apps/api/tests/test_required_params.py | 215 +++++++++++++++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 apps/api/tests/test_required_params.py diff --git a/apps/api/tests/test_contract.py b/apps/api/tests/test_contract.py index 46d6c2b..ae56d24 100644 --- a/apps/api/tests/test_contract.py +++ b/apps/api/tests/test_contract.py @@ -249,3 +249,126 @@ def test_error_bodies_carry_only_epoint_fields(client, merchant): body = client.post("/api/1/request", data={"data": data, "signature": signature}).json() assert set(body) <= {"status", "message", "code", "trace_id"} + + +# Fields the docs list that the sandbox cannot produce at this point in the flow. +WITHHELD: dict[str, set[str]] = { + # Docs omit `transaction` here where every sibling includes it. Withheld until confirmed. + "/api/1/payment-change-sum": {"transaction"}, + # The documented "response" carries rrn and operation_code 200, which only exist once the + # customer has paid. It reads as the callback payload, mislabelled. Needs a production capture. + "/api/1/card-registration-with-pay": { + "amount", + "bank_response", + "bank_transaction", + "card_mask", + "card_name", + "code", + "operation_code", + "order_id", + "other_attr", + "rrn", + }, +} + + +def assert_documented_fields_present(path: str, body: dict) -> None: + """The other direction: a documented field the sandbox omits reads as undefined.""" + missing = ( + set(DOCUMENTED_RESPONSE_FIELDS.get(path, set())) - set(body) - WITHHELD.get(path, set()) + ) + assert not missing, ( + f"{path} omits documented field(s) {sorted(missing)}. " + "An integration reading them gets undefined here and a value in production." + ) + + +@pytest.mark.parametrize( + "path", + [ + "/api/1/request", + "/api/1/payment-request", + "/api/1/amex-request", + "/api/1/payment-change-sum", + "/api/1/pre-auth-request", + ], +) +def test_payment_family_returns_every_documented_field(client, merchant, path): + body = call(client, merchant, path, {**PAYMENT, "order_id": f"p{path[-7:]}"}) + assert_documented_fields_present(path, body) + + +def test_split_request_returns_every_documented_field(client, merchants): + body = call( + client, + merchants[0], + "/api/1/split-request", + { + **PAYMENT, + "order_id": "p-split", + "split_user": merchants[1].public_key, + "split_amount": "10.00", + }, + ) + assert_documented_fields_present("/api/1/split-request", body) + + +def test_reverse_returns_every_documented_field(client, merchant): + body = call(client, merchant, "/api/1/request", {**PAYMENT, "order_id": "p-rev"}) + pay_checkout(client, token_of(body)) + reversed_body = call( + client, + merchant, + "/api/1/reverse", + {"language": "en", "transaction": body["transaction"], "currency": "AZN"}, + ) + assert_documented_fields_present("/api/1/reverse", reversed_body) + + +def test_wallet_and_installment_return_every_documented_field(client, merchant): + wallet = call( + client, + merchant, + "/api/1/wallet/payment", + {**PAYMENT, "order_id": "p-wallet", "wallet_id": "wallet_epul"}, + ) + assert_documented_fields_present("/api/1/wallet/payment", wallet) + + installment = call( + client, + merchant, + "/api/1/installment-request", + {**PAYMENT, "order_id": "p-inst", "installment_card_id": "1", "installment_month": 3}, + ) + assert_documented_fields_present("/api/1/installment-request", installment) + + +INVOICE = { + "sum": "25.00", + "display": 1, + "save_as_template": 0, + "period_from": "2026-01-01", + "period_to": "2026-12-31", + "phone": "+994501234567", + "email": "buyer@example.com", +} + + +def test_invoice_endpoints_return_every_documented_field(client, merchant): + created = call(client, merchant, "/api/1/invoices/create", INVOICE) + assert_documented_fields_present("/api/1/invoices/create", created) + + invoice_id = created["id"] + for path, extra in [ + ("/api/1/invoices/view", {"id": invoice_id}), + ("/api/1/invoices/list", {}), + ("/api/1/invoices/send-sms", {"id": invoice_id, "phone": "+994501234567"}), + ("/api/1/invoices/send-email", {"id": invoice_id, "email": "buyer@example.com"}), + ]: + assert_documented_fields_present(path, call(client, merchant, path, extra)) + + +def test_withheld_fields_are_recorded_as_unverified(): + """Withholding is only defensible if the gap is declared.""" + for path in WITHHELD: + assert path in UNVERIFIED, f"{path} withholds documented fields without saying why" diff --git a/apps/api/tests/test_required_params.py b/apps/api/tests/test_required_params.py new file mode 100644 index 0000000..0838941 --- /dev/null +++ b/apps/api/tests/test_required_params.py @@ -0,0 +1,215 @@ +"""Refuse what production refuses. + +A sandbox that accepts a payload production rejects is the dangerous direction: the +developer never learns their request is wrong until they switch over. Every field here +is marked Required on developer.epoint.az. +""" + +import pytest + +from epoint_sandbox.services import magic_cards +from tests.helpers import call, enable_features, pay_checkout, token_of + + +@pytest.fixture(autouse=True) +def _granted(client, merchant): + enable_features(client, merchant) + + +@pytest.fixture +def active_card(client, merchant): + body = call(client, merchant, "/api/1/card-registration", {"language": "en"}) + pay_checkout(client, token_of(body), magic_cards.SUCCESS_CARD) + return body["card_id"] + + +@pytest.fixture +def settled(client, merchant): + body = call( + client, + merchant, + "/api/1/request", + {"amount": "80.00", "currency": "AZN", "language": "en", "order_id": "req-seed"}, + ) + pay_checkout(client, token_of(body)) + return body["transaction"] + + +@pytest.fixture +def invoice(client, merchant): + return call( + client, + merchant, + "/api/1/invoices/create", + { + "sum": "10.00", + "display": 1, + "save_as_template": 0, + "period_from": "2026-01-01", + "period_to": "2026-12-31", + }, + )["id"] + + +PAYMENT = {"amount": "10.00", "currency": "AZN", "language": "en"} + +PAYMENT_FAMILY = [ + "/api/1/request", + "/api/1/payment-request", + "/api/1/amex-request", + "/api/1/payment-change-sum", + "/api/1/pre-auth-request", +] + + +def refused(body: dict, field: str) -> None: + assert body.get("status") in ("error", "failed"), ( + f"accepted a payload with no {field!r}, which production documents as required. " + "A developer would never learn their request is incomplete." + ) + assert field in body.get("message", ""), f"the refusal does not name {field!r}" + + +@pytest.mark.parametrize("path", PAYMENT_FAMILY) +@pytest.mark.parametrize("field", ["amount", "currency", "language", "order_id"]) +def test_payment_family_requires_its_documented_fields(client, merchant, path, field): + payload = {**PAYMENT, "order_id": f"r{path[-6:]}{field}"} + del payload[field] + refused(call(client, merchant, path, payload), field) + + +@pytest.mark.parametrize( + "field", ["amount", "currency", "language", "order_id", "split_user", "split_amount"] +) +def test_split_request_requires_its_documented_fields(client, merchants, field): + payload = { + **PAYMENT, + "order_id": f"rsplit{field}", + "split_user": merchants[1].public_key, + "split_amount": "3.00", + } + del payload[field] + refused(call(client, merchants[0], "/api/1/split-request", payload), field) + + +@pytest.mark.parametrize("field", ["language", "card_id", "order_id", "amount", "currency"]) +def test_execute_pay_requires_its_documented_fields(client, merchant, active_card, field): + payload = { + "language": "en", + "card_id": active_card, + "order_id": f"rexec{field}", + "amount": "5.00", + "currency": "AZN", + } + del payload[field] + refused(call(client, merchant, "/api/1/execute-pay", payload), field) + + +@pytest.mark.parametrize("field", ["language", "card_id", "order_id", "amount", "currency"]) +def test_refund_request_requires_its_documented_fields(client, merchant, active_card, field): + payload = { + "language": "en", + "card_id": active_card, + "order_id": f"rrefund{field}", + "amount": "1.00", + "currency": "AZN", + } + del payload[field] + refused(call(client, merchant, "/api/1/refund-request", payload), field) + + +@pytest.mark.parametrize("field", ["language", "transaction", "currency"]) +def test_reverse_requires_its_documented_fields(client, merchant, settled, field): + payload = {"language": "en", "transaction": settled, "currency": "AZN"} + del payload[field] + refused(call(client, merchant, "/api/1/reverse", payload), field) + + +@pytest.mark.parametrize("field", ["wallet_id", "amount", "currency", "order_id", "language"]) +def test_wallet_payment_requires_its_documented_fields(client, merchant, field): + payload = {**PAYMENT, "order_id": f"rwallet{field}", "wallet_id": "wallet_epul"} + del payload[field] + refused(call(client, merchant, "/api/1/wallet/payment", payload), field) + + +@pytest.mark.parametrize( + "field", ["amount", "currency", "language", "order_id", "installment_card_id"] +) +def test_installment_request_requires_its_documented_fields(client, merchant, field): + payload = { + **PAYMENT, + "order_id": f"rinst{field}", + "installment_card_id": "1", + "installment_month": 3, + } + del payload[field] + refused(call(client, merchant, "/api/1/installment-request", payload), field) + + +@pytest.mark.parametrize("field", ["language", "order_id", "amount", "currency"]) +def test_register_and_pay_requires_its_documented_fields(client, merchant, field): + payload = { + "language": "en", + "order_id": f"rregpay{field}", + "amount": "10.00", + "currency": "AZN", + } + del payload[field] + refused(call(client, merchant, "/api/1/card-registration-with-pay", payload), field) + + +@pytest.mark.parametrize( + "field", ["sum", "display", "save_as_template", "period_from", "period_to"] +) +def test_invoice_create_requires_its_documented_fields(client, merchant, field): + payload = { + "sum": "10.00", + "display": 1, + "save_as_template": 0, + "period_from": "2026-01-01", + "period_to": "2026-12-31", + } + del payload[field] + refused(call(client, merchant, "/api/1/invoices/create", payload), field) + + +@pytest.mark.parametrize("field", ["sum", "display", "save_as_template"]) +def test_invoice_update_requires_its_documented_fields(client, merchant, invoice, field): + payload = { + "id": invoice, + "sum": "12.00", + "display": 1, + "save_as_template": 0, + "period_from": "2026-01-01", + "period_to": "2026-12-31", + } + del payload[field] + refused(call(client, merchant, "/api/1/invoices/update", payload), field) + + +def test_zero_is_a_value_not_an_omission(client, merchant): + """display=0 and save_as_template=0 are meaningful, not missing.""" + body = call( + client, + merchant, + "/api/1/invoices/create", + { + "sum": "10.00", + "display": 0, + "save_as_template": 0, + "period_from": "2026-01-01", + "period_to": "2026-12-31", + }, + ) + assert body["status"] == "success" + + +def test_the_widget_does_not_require_language(client, merchant): + """Its doc page lists only public_key, amount, order_id and description.""" + body = call( + client, + merchant, + "/api/1/token/widget", + {"amount": "10.00", "order_id": "rwidget", "description": "no language"}, + ) + assert body["status"] == "success"