//go:build integration package inttest import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "sync" "testing" "github.com/google/uuid" "go.uber.org/zap" "scrabble/backend/internal/account" "scrabble/backend/internal/payments" "scrabble/backend/internal/server" "scrabble/backend/internal/yookassa" ) // The test shop the fake API answers for. IsTest is true throughout, matching a YooKassa test shop. const ( ykShopID = "100500" ykSecretKey = "test_secret" // ykSenderIP is inside YooKassa's published notification ranges; the intake rejects anything else. ykSenderIP = "185.71.76.1" ) // fakeYooKassa is a stand-in for the YooKassa API. Tests set the payment it reports and whether a // refund succeeds, then assert on what the intake did with that answer. type fakeYooKassa struct { mu sync.Mutex // payments answers GET /payments/{id}; a missing id is a 404, as it is for a payment we never made. payments map[string]yookassa.Payment // refundFails makes POST /refunds answer 500, standing in for a provider that did not move money. refundFails bool // refundCalls counts the refunds actually requested. refundCalls int // createdIdempotenceKey records the key sent on the last create-payment call. createdIdempotenceKey string // lastReceipt records the receipt object of the last create-payment call. lastReceipt map[string]any } // newFakeYooKassa starts the fake API and returns it with a shop config pointed at it. func newFakeYooKassa(t *testing.T) (*fakeYooKassa, yookassa.Config) { t.Helper() f := &fakeYooKassa{payments: map[string]yookassa.Payment{}} srv := httptest.NewServer(http.HandlerFunc(f.serve)) t.Cleanup(srv.Close) return f, yookassa.Config{ShopID: ykShopID, SecretKey: ykSecretKey, IsTest: true, BaseURL: srv.URL} } func (f *fakeYooKassa) serve(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() w.Header().Set("Content-Type", "application/json") switch { case r.Method == http.MethodPost && r.URL.Path == "/payments": var body struct { Amount yookassa.Amount `json:"amount"` Metadata map[string]string `json:"metadata"` Receipt map[string]any `json:"receipt"` } _ = json.NewDecoder(r.Body).Decode(&body) f.createdIdempotenceKey = r.Header.Get("Idempotence-Key") f.lastReceipt = body.Receipt id := "pay-" + body.Metadata[yookassa.MetadataOrderID] p := yookassa.Payment{ ID: id, Status: yookassa.StatusPending, Test: true, Amount: body.Amount, Confirmation: yookassa.Confirmation{Type: yookassa.ConfirmationRedirect, ConfirmationURL: "https://yoomoney.test/pay/" + id}, Metadata: body.Metadata, Recipient: yookassa.Recipient{AccountID: ykShopID}, } f.payments[id] = p _ = json.NewEncoder(w).Encode(p) case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/payments/"): p, ok := f.payments[strings.TrimPrefix(r.URL.Path, "/payments/")] if !ok { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"type":"error","description":"not found"}`)) return } _ = json.NewEncoder(w).Encode(p) case r.Method == http.MethodPost && r.URL.Path == "/refunds": if f.refundFails { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"type":"error","description":"acquirer unavailable"}`)) return } f.refundCalls++ var body yookassa.RefundRequest _ = json.NewDecoder(r.Body).Decode(&body) _ = json.NewEncoder(w).Encode(yookassa.Refund{ ID: "refund-1", Status: yookassa.StatusSucceeded, PaymentID: body.PaymentID, Amount: body.Amount, }) default: w.WriteHeader(http.StatusNotFound) } } // setPayment overwrites what the API reports for a payment — how a test says "the money did (or did // not) really move", independently of what a notification claims. func (f *fakeYooKassa) setPayment(id string, mutate func(p *yookassa.Payment)) { f.mu.Lock() defer f.mu.Unlock() p := f.payments[id] p.ID = id mutate(&p) f.payments[id] = p } // yookassaServer builds a backend server whose direct rail is the fake YooKassa shop. // // The suite shares one database, and the kill-switch test leaves the direct rail disabled, so the // rail status is reset first: these tests are about the provider, not about ops availability, and // fail-open means an absent row is an enabled rail. func yookassaServer(t *testing.T, shop yookassa.Config) (*server.Server, *payments.Service) { t.Helper() if _, err := testDB.ExecContext(context.Background(), `DELETE FROM payments.rail_status WHERE rail LIKE 'direct%'`); err != nil { t.Fatalf("reset direct rail status: %v", err) } paySvc := newPaymentsService() srv := server.New(":0", server.Deps{ Logger: zap.NewNop(), Accounts: account.NewStore(testDB), Games: newGameService(), Registry: testRegistry, DictDir: dictDir(), Payments: paySvc, YooKassa: yookassa.Shops{yookassa.ChannelWeb: shop}, YooKassaVatCode: yookassa.VatCodeNone, PublicBaseURL: "https://scrabble.test", }) return srv, paySvc } // postYooKassaNotify posts a notification body to the intake as the given sender, and reports the status code. func postYooKassaNotify(t *testing.T, srv *server.Server, senderIP, event, paymentID, orderID string) int { t.Helper() body := fmt.Sprintf(`{"type":"notification","event":%q,"object":{"id":%q,"status":"succeeded", "recipient":{"account_id":%q},"metadata":{"order_id":%q}}}`, event, paymentID, ykShopID, orderID) req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/payments/yookassa/notify", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Forwarded-For", senderIP) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, req) return rec.Code } // seedYooKassaOrder opens a paid-for-real order: a pending order with the provider payment id // attached, exactly as the order path leaves it once YooKassa has minted the payment. func seedYooKassaOrder(t *testing.T, f *fakeYooKassa, pay *payments.Service, acc uuid.UUID) (uuid.UUID, string) { t.Helper() ctx := context.Background() prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900}) res, err := pay.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "yookassa") if err != nil { t.Fatalf("create order: %v", err) } paymentID := "pay-" + res.OrderID.String() f.setPayment(paymentID, func(p *yookassa.Payment) { p.Status = yookassa.StatusSucceeded p.Paid = true p.Test = true p.Amount = yookassa.Amount{Value: "149.00", Currency: "RUB"} p.Metadata = map[string]string{yookassa.MetadataOrderID: res.OrderID.String()} p.Recipient = yookassa.Recipient{AccountID: ykShopID} }) if err := pay.AttachProviderPayment(ctx, res.OrderID, "yookassa", paymentID); err != nil { t.Fatalf("attach provider payment: %v", err) } return res.OrderID, paymentID } // TestYooKassaNotifyCreditsOnce drives the crediting path: a succeeded notification credits the // order exactly once, and a redelivery credits nothing more. func TestYooKassaNotifyCreditsOnce(t *testing.T) { f, shop := newFakeYooKassa(t) srv, pay := yookassaServer(t, shop) acc := provisionAccount(t) orderID, paymentID := seedYooKassaOrder(t, f, pay, acc) if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK { t.Fatalf("notify = %d, want 200", code) } if got := readBalance(t, acc, "direct"); got != 100 { t.Errorf("balance = %d, want 100", got) } if orderStatus(t, orderID) != "paid" { t.Errorf("order status = %s, want paid", orderStatus(t, orderID)) } // YooKassa redelivers until it gets a 200; a redelivery must credit nothing and still answer 200. if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK { t.Fatalf("redelivered notify = %d, want 200", code) } if got := readBalance(t, acc, "direct"); got != 100 { t.Errorf("balance after redelivery = %d, want 100 (credited once)", got) } if ledgerRows(t, acc, "fund") != 1 { t.Errorf("fund ledger rows = %d, want 1", ledgerRows(t, acc, "fund")) } } // TestYooKassaNotifyIsNotEvidence is the security property of the whole rail: notifications are // unsigned, so a fabricated "succeeded" credits nothing when the confirming read says otherwise. func TestYooKassaNotifyIsNotEvidence(t *testing.T) { f, shop := newFakeYooKassa(t) srv, pay := yookassaServer(t, shop) acc := provisionAccount(t) orderID, paymentID := seedYooKassaOrder(t, f, pay, acc) // The provider says the payment never completed, whatever the notification claims. f.setPayment(paymentID, func(p *yookassa.Payment) { p.Status = yookassa.StatusPending; p.Paid = false }) if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK { t.Fatalf("notify = %d, want 200", code) } if got := readBalance(t, acc, "direct"); got != 0 { t.Errorf("balance = %d, want 0 — a forged notification credited chips", got) } // A payment we never made: the confirming read 404s, so there is nothing to credit. if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, "pay-invented", orderID.String()); code != http.StatusOK { t.Fatalf("notify for an unknown payment = %d, want 200", code) } if got := readBalance(t, acc, "direct"); got != 0 { t.Errorf("balance = %d, want 0 — an invented payment credited chips", got) } } // TestYooKassaNotifyRejectsForeignSenders checks the sender allowlist: an address outside YooKassa's // published ranges is refused before any provider call, so a forger cannot make us fan out requests. func TestYooKassaNotifyRejectsForeignSenders(t *testing.T) { f, shop := newFakeYooKassa(t) srv, pay := yookassaServer(t, shop) acc := provisionAccount(t) orderID, paymentID := seedYooKassaOrder(t, f, pay, acc) if code := postYooKassaNotify(t, srv, "8.8.8.8", yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusForbidden { t.Fatalf("notify from a foreign address = %d, want 403", code) } if got := readBalance(t, acc, "direct"); got != 0 { t.Errorf("balance = %d, want 0 — a foreign sender credited chips", got) } } // TestYooKassaNotifyRejectsLiveOnTestShop is the guard that keeps play money out of a live ledger and // real money out of a test one: a payment whose test flag disagrees with the shop's is not credited. func TestYooKassaNotifyRejectsLiveOnTestShop(t *testing.T) { f, shop := newFakeYooKassa(t) srv, pay := yookassaServer(t, shop) // the shop is a test shop acc := provisionAccount(t) orderID, paymentID := seedYooKassaOrder(t, f, pay, acc) f.setPayment(paymentID, func(p *yookassa.Payment) { p.Test = false }) // a live payment if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK { t.Fatalf("notify = %d, want 200", code) } if got := readBalance(t, acc, "direct"); got != 0 { t.Errorf("balance = %d, want 0 — a live payment credited against test credentials", got) } } // TestYooKassaCanceledRecordsFailure checks an active decline records a failed payment event and // credits nothing, so the customer learns the attempt did not go through. func TestYooKassaCanceledRecordsFailure(t *testing.T) { f, shop := newFakeYooKassa(t) srv, pay := yookassaServer(t, shop) acc := provisionAccount(t) orderID, paymentID := seedYooKassaOrder(t, f, pay, acc) f.setPayment(paymentID, func(p *yookassa.Payment) { p.Status = yookassa.StatusCanceled p.Paid = false p.CancellationDetails = &yookassa.Cancellation{Party: "payment_network", Reason: "insufficient_funds"} }) if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentCanceled, paymentID, orderID.String()); code != http.StatusOK { t.Fatalf("notify = %d, want 200", code) } if got := readBalance(t, acc, "direct"); got != 0 { t.Errorf("balance = %d, want 0 — a canceled payment credited chips", got) } if orderStatus(t, orderID) != "pending" { t.Errorf("order status = %s, want pending (a decline does not settle the order)", orderStatus(t, orderID)) } evs, err := pay.UndispatchedEvents(context.Background(), 50) if err != nil { t.Fatalf("read events: %v", err) } found := false for _, e := range evs { if e.AccountID == acc && e.Type == "failed" { found = true } } if !found { t.Error("no failed payment event recorded for a declined payment") } } // TestYooKassaReconcileCreditsLostNotification is the safety net: when no notification ever arrives, // the expiry-time check asks the provider and credits an order that was in fact paid. func TestYooKassaReconcileCreditsLostNotification(t *testing.T) { f, shop := newFakeYooKassa(t) srv, pay := yookassaServer(t, shop) acc := provisionAccount(t) orderID, _ := seedYooKassaOrder(t, f, pay, acc) // Age the order past its lifetime without any notification having been delivered. if _, err := testDB.ExecContext(context.Background(), `UPDATE payments.orders SET created_at = now() - interval '2 days' WHERE order_id=$1`, orderID); err != nil { t.Fatalf("age order: %v", err) } if n := srv.ReconcileYooKassaOrders(context.Background()); n != 1 { t.Fatalf("reconciled %d orders, want 1", n) } if got := readBalance(t, acc, "direct"); got != 100 { t.Errorf("balance = %d, want 100 — the safety net did not credit a paid order", got) } if orderStatus(t, orderID) != "paid" { t.Errorf("order status = %s, want paid", orderStatus(t, orderID)) } // A second sweep finds nothing left to do — the order is no longer pending. if n := srv.ReconcileYooKassaOrders(context.Background()); n != 0 { t.Errorf("second sweep credited %d orders, want 0", n) } } // TestYooKassaReconcileLeavesUnpaidOrders checks the sweep does not invent a credit for an order the // customer abandoned. func TestYooKassaReconcileLeavesUnpaidOrders(t *testing.T) { f, shop := newFakeYooKassa(t) srv, pay := yookassaServer(t, shop) acc := provisionAccount(t) orderID, paymentID := seedYooKassaOrder(t, f, pay, acc) f.setPayment(paymentID, func(p *yookassa.Payment) { p.Status = yookassa.StatusCanceled; p.Paid = false }) if _, err := testDB.ExecContext(context.Background(), `UPDATE payments.orders SET created_at = now() - interval '2 days' WHERE order_id=$1`, orderID); err != nil { t.Fatalf("age order: %v", err) } if n := srv.ReconcileYooKassaOrders(context.Background()); n != 0 { t.Errorf("reconciled %d orders, want 0 (the payment was never completed)", n) } if got := readBalance(t, acc, "direct"); got != 0 { t.Errorf("balance = %d, want 0", got) } } // TestYooKassaConsoleRefundMovesMoneyThenRecords drives the operator refund end to end: the provider // refund is requested first and the ledger records the provider's own refund id. func TestYooKassaConsoleRefundMovesMoneyThenRecords(t *testing.T) { f, shop := newFakeYooKassa(t) srv, pay := yookassaServer(t, shop) acc := provisionAccount(t) orderID, paymentID := seedYooKassaOrder(t, f, pay, acc) if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK { t.Fatalf("notify = %d, want 200", code) } h := srv.Handler() base := "http://admin.test/_gm/users/" + acc.String() code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test") if code != http.StatusOK || !strings.Contains(body, "revoked 100 chips") { t.Fatalf("refund = %d, body has 'revoked 100 chips' = %v", code, strings.Contains(body, "revoked 100 chips")) } if f.refundCalls != 1 { t.Errorf("provider refunds requested = %d, want 1", f.refundCalls) } if got := readBalance(t, acc, "direct"); got != 0 { t.Errorf("balance after refund = %d, want 0", got) } // The ledger records the provider's own refund id, which is what keeps it reconcilable against // YooKassa's records — and is distinct from the payment id, so both rows coexist. var provider, refundID string if err := testDB.QueryRowContext(context.Background(), `SELECT provider, provider_payment_id FROM payments.ledger WHERE account_id=$1 AND kind='refund'`, acc).Scan(&provider, &refundID); err != nil { t.Fatalf("read refund ledger row: %v", err) } if provider != "yookassa" || refundID != "refund-1" { t.Errorf("refund ledger row = (%s, %s), want (yookassa, refund-1)", provider, refundID) } } // TestYooKassaRefundRecordsNothingWhenTheProviderFails is the invariant that keeps the ledger honest: // if the money did not move, nothing is written. func TestYooKassaRefundRecordsNothingWhenTheProviderFails(t *testing.T) { f, shop := newFakeYooKassa(t) srv, pay := yookassaServer(t, shop) acc := provisionAccount(t) orderID, paymentID := seedYooKassaOrder(t, f, pay, acc) if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK { t.Fatalf("notify = %d, want 200", code) } f.refundFails = true h := srv.Handler() base := "http://admin.test/_gm/users/" + acc.String() _, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test") if !strings.Contains(body, "nothing was recorded") { t.Errorf("refund failure message = %q, want it to say nothing was recorded", body) } if got := readBalance(t, acc, "direct"); got != 100 { t.Errorf("balance = %d, want 100 — chips were revoked without the money moving", got) } if ledgerRows(t, acc, "refund") != 0 { t.Error("a refund ledger row was written although the provider did not refund") } } // TestYooKassaOrderMintsAPaymentWithAReceipt drives the purchase path over HTTP: the order endpoint // calls the provider, threads the order id through, sends the fiscal receipt to the buyer's confirmed // address, and hands the client the hosted payment page. func TestYooKassaOrderMintsAPaymentWithAReceipt(t *testing.T) { f, shop := newFakeYooKassa(t) srv, _ := yookassaServer(t, shop) acc := provisionAccount(t) const email = "buyer@example.test" if _, err := testDB.ExecContext(context.Background(), `INSERT INTO backend.identities (identity_id, account_id, kind, external_id, confirmed) VALUES ($1,$2,'email',$3,true)`, uuid.New(), acc, email); err != nil { t.Fatalf("seed email identity: %v", err) } prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900}) req := httptest.NewRequest(http.MethodPost, "/api/v1/user/wallet/order", strings.NewReader(fmt.Sprintf(`{"product_id":%q}`, prod))) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-User-ID", acc.String()) req.Header.Set("X-Platform", "direct/web") rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("order = %d, want 200: %s", rec.Code, rec.Body.String()) } var out struct { OrderID string `json:"order_id"` RedirectURL string `json:"redirect_url"` Rail string `json:"rail"` } if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("decode order response: %v", err) } if out.Rail != "yookassa" || !strings.HasPrefix(out.RedirectURL, "https://yoomoney.test/pay/") { t.Errorf("order response = %+v, want the yookassa rail and its hosted payment page", out) } // The order id is both the idempotency key (so a retried create cannot mint a second payment) // and the metadata that later resolves a notification to this order. if f.createdIdempotenceKey != out.OrderID { t.Errorf("Idempotence-Key = %q, want the order id %q", f.createdIdempotenceKey, out.OrderID) } customer, _ := f.lastReceipt["customer"].(map[string]any) if customer["email"] != email { t.Errorf("receipt customer = %v, want the confirmed email %q", customer, email) } items, _ := f.lastReceipt["items"].([]any) if len(items) != 1 { t.Fatalf("receipt items = %d, want 1", len(items)) } item, _ := items[0].(map[string]any) if item["vat_code"] != float64(yookassa.VatCodeNone) || item["payment_subject"] != yookassa.PaymentSubjectService { t.Errorf("receipt fiscal attributes = %v", item) } // The payment id is recorded on the order, which is what the safety net and a refund need. orderID, err := uuid.Parse(out.OrderID) if err != nil { t.Fatalf("parse order id: %v", err) } var paymentID string if err := testDB.QueryRowContext(context.Background(), `SELECT provider_payment_id FROM payments.orders WHERE order_id=$1`, orderID).Scan(&paymentID); err != nil { t.Fatalf("read order payment id: %v", err) } if paymentID != "pay-"+out.OrderID { t.Errorf("order provider_payment_id = %q, want the minted payment id", paymentID) } } // TestYooKassaOrderRequiresAnEmailAnchor keeps D36 in force on the new rail: without a confirmed // address there is no recovery anchor and nowhere to deliver the fiscal receipt, so no payment is // minted at all. func TestYooKassaOrderRequiresAnEmailAnchor(t *testing.T) { _, shop := newFakeYooKassa(t) srv, _ := yookassaServer(t, shop) acc := provisionAccount(t) // a telegram identity only — no confirmed email prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900}) req := httptest.NewRequest(http.MethodPost, "/api/v1/user/wallet/order", strings.NewReader(fmt.Sprintf(`{"product_id":%q}`, prod))) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-User-ID", acc.String()) req.Header.Set("X-Platform", "direct/web") rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "email_required") { t.Fatalf("order = %d (%s), want 403 email_required", rec.Code, rec.Body.String()) } }