4cac09c9f3
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
A real test payment on the contour exposed both problems at once. YooKassa
delivered the notification five times; all five were rejected because the
backend saw the sender as 10.77.0.1 — the contour sits behind a tunnel and
cannot observe real client addresses, the same reason the IP bans in this
repository are prod-only. The chips were not lost (the reconcile sweep would
have credited them), but the primary path was dead and the customer was left
watching an unchanged balance.
The address check is removed rather than made conditional. It never was the
security boundary — the confirming GET /v3/payments/{id} is — and the one thing
it bought is already bought earlier and far more tightly: the order is resolved
from the notification's metadata *before* any provider call, so a notification
naming no known order costs a single indexed read and stops there. Guessing a
live order id means guessing a uuid. Against that, an address check adds nothing
and breaks every deployment that cannot see real client addresses, while turning
any future change to YooKassa's published ranges into a silent degradation.
The second problem was mine. The reconcile threshold was keyed off the order
lifetime, so a lost notification cost the customer the full 30-minute TTL before
the chips landed. Those are different questions: the lifetime governs how long a
customer may take to pay, the re-check governs how soon we notice a lost
callback. Split apart — `payments.ReconcileAfter`, one minute, swept on every
reaper tick. The bound D49 was chosen for survives: the calls one order can
cause are still its lifetime divided by the sweep interval, a handful, not an
open-ended poll. Worst case for a failed notification drops from ~30 minutes to
~5; an order the customer is still paying for is left alone.
Tests: the foreign-sender test is replaced by the two properties that now carry
the load — a notification naming an unknown order makes no provider call at all,
and a genuine notification is honoured whatever address it appears to come from.
Plus one pinning that a seconds-old order is not polled.
The shared bundle budget goes 31 -> 32 KB, with the reason recorded in the
script header: every user-visible string lands in that chunk and it had been
sitting 40 bytes under the cap.
Decisions D48 and D49 revised.
81 lines
2.6 KiB
Go
81 lines
2.6 KiB
Go
package yookassa
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
func TestParseNotification(t *testing.T) {
|
|
n, err := ParseNotification([]byte(`{"type":"notification","event":"payment.succeeded",
|
|
"object":{"id":"pay-1","status":"succeeded","paid":true,"metadata":{"order_id":"0197-order"}}}`))
|
|
if err != nil {
|
|
t.Fatalf("parse notification: %v", err)
|
|
}
|
|
if n.Event != EventPaymentSucceeded {
|
|
t.Errorf("event = %q, want %q", n.Event, EventPaymentSucceeded)
|
|
}
|
|
p, err := n.Payment()
|
|
if err != nil {
|
|
t.Fatalf("decode notification payment: %v", err)
|
|
}
|
|
if p.ID != "pay-1" {
|
|
t.Errorf("payment id = %q, want pay-1", p.ID)
|
|
}
|
|
if p.OrderID() != "0197-order" {
|
|
t.Errorf("order id = %q, want the order carried in metadata", p.OrderID())
|
|
}
|
|
}
|
|
|
|
func TestParseNotificationRejectsNonEnvelopes(t *testing.T) {
|
|
for name, body := range map[string]string{
|
|
"not json": `nonsense`,
|
|
"wrong type": `{"type":"payment","event":"payment.succeeded","object":{"id":"pay-1"}}`,
|
|
"no event": `{"type":"notification","object":{"id":"pay-1"}}`,
|
|
"empty object": `{}`,
|
|
} {
|
|
if _, err := ParseNotification([]byte(body)); err == nil {
|
|
t.Errorf("%s: accepted as a notification envelope", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNotificationPaymentNeedsAnID(t *testing.T) {
|
|
// Without an id there is nothing to re-read from the API, and the body itself is never evidence.
|
|
n, err := ParseNotification([]byte(`{"type":"notification","event":"payment.succeeded","object":{"status":"succeeded"}}`))
|
|
if err != nil {
|
|
t.Fatalf("parse notification: %v", err)
|
|
}
|
|
if _, err := n.Payment(); err == nil {
|
|
t.Error("a payment object with no id was accepted")
|
|
}
|
|
}
|
|
|
|
func TestParseRefundNotification(t *testing.T) {
|
|
n, err := ParseNotification([]byte(`{"type":"notification","event":"refund.succeeded",
|
|
"object":{"id":"refund-1","status":"succeeded","payment_id":"pay-1",
|
|
"amount":{"value":"149.00","currency":"RUB"}}}`))
|
|
if err != nil {
|
|
t.Fatalf("parse notification: %v", err)
|
|
}
|
|
if n.Event != EventRefundSucceeded {
|
|
t.Errorf("event = %q, want %q", n.Event, EventRefundSucceeded)
|
|
}
|
|
r, err := n.Refund()
|
|
if err != nil {
|
|
t.Fatalf("decode notification refund: %v", err)
|
|
}
|
|
// The refund names the payment, not our order — that is how it is resolved back to one.
|
|
if r.ID != "refund-1" || r.PaymentID != "pay-1" {
|
|
t.Errorf("refund = %+v, want refund-1 for pay-1", r)
|
|
}
|
|
}
|
|
|
|
func TestNotificationRefundNeedsAnID(t *testing.T) {
|
|
n, err := ParseNotification([]byte(`{"type":"notification","event":"refund.succeeded","object":{"status":"succeeded"}}`))
|
|
if err != nil {
|
|
t.Fatalf("parse notification: %v", err)
|
|
}
|
|
if _, err := n.Refund(); err == nil {
|
|
t.Error("a refund object with no id was accepted")
|
|
}
|
|
}
|