Files
bank/webhook_test.go
zeekayandHanzo Dev 9b28d8ca64 bank: cover rail fees, status mappers, CC conversion, freeze audit (70% -> 73%)
Table tests for the pure mappers — RailFee (every rail + default),
mapCCStatus, mapIFXStatus (every case + default). The CC conversion
webhook reconciles (completing the payment/conversion/IFX webhook set),
and freezing an account is proven to write an immutable audit-log entry.
Total ./... coverage 69.9% -> 73.1%.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-22 16:41:20 -07:00

144 lines
5.0 KiB
Go

package bank
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tests"
"github.com/luxfi/bank/collections"
)
func hmacSign(secret, body string) string {
m := hmac.New(sha256.New, []byte(secret))
m.Write([]byte(body))
return hex.EncodeToString(m.Sum(nil))
}
// txWithCCID creates a pending transaction tagged with a CurrencyCloud id so a
// webhook has something to reconcile against.
func txWithCCID(t *testing.T, app core.App, acctID, ccID string) {
t.Helper()
col, _ := app.FindCollectionByNameOrId(collections.TransactionCollectionName)
r := core.NewRecord(col)
r.Set("account", acctID)
r.Set("type", "payment")
r.Set("direction", "credit")
r.Set("amount", 10_00)
r.Set("currency", "USD")
r.Set("status", "pending")
r.Set("ccTransactionId", ccID)
if err := app.Save(r); err != nil {
t.Fatalf("save cc tx: %v", err)
}
}
func TestCurrencyCloudWebhooks(t *testing.T) {
const secret = "wh-secret"
t.Setenv("WEBHOOK_HMAC_SECRET", secret)
app := newBankApp(t)
id, _ := seedPrincipal(t, app)
acct := primaryAccount(app, id)
txWithCCID(t, app, acct.Id, "cc-pay-1")
txWithCCID(t, app, acct.Id, "ifx-1")
pay := `{"id":"cc-pay-1","status":"completed"}`
run(t, app, tests.ApiScenario{
Name: "cc payment webhook reconciles",
Method: http.MethodPost,
URL: "/v1/bank/webhooks/currencycloud/payment",
Body: strings.NewReader(pay),
Headers: map[string]string{"Content-Type": "application/json", "X-Signature": hmacSign(secret, pay)},
ExpectedStatus: 200,
ExpectedContent: []string{`"status"`},
})
// Bad signature is refused by the HMAC gate.
run(t, app, tests.ApiScenario{
Name: "cc payment webhook rejects a bad signature",
Method: http.MethodPost,
URL: "/v1/bank/webhooks/currencycloud/payment",
Body: strings.NewReader(pay),
Headers: map[string]string{"Content-Type": "application/json", "X-Signature": "bad"},
ExpectedStatus: 401,
ExpectedContent: []string{`"error"`},
})
// Unknown id is accepted (200 ignored) so the upstream does not retry.
unknown := `{"id":"nope","status":"completed"}`
run(t, app, tests.ApiScenario{
Name: "cc payment webhook ignores an unknown id",
Method: http.MethodPost,
URL: "/v1/bank/webhooks/currencycloud/payment",
Body: strings.NewReader(unknown),
Headers: map[string]string{"Content-Type": "application/json", "X-Signature": hmacSign(secret, unknown)},
ExpectedStatus: 200,
ExpectedContent: []string{`"ignored"`},
})
txWithCCID(t, app, acct.Id, "cc-conv-1")
conv := `{"id":"cc-conv-1","status":"completed"}`
run(t, app, tests.ApiScenario{
Name: "cc conversion webhook reconciles",
Method: http.MethodPost,
URL: "/v1/bank/webhooks/currencycloud/conversion",
Body: strings.NewReader(conv),
Headers: map[string]string{"Content-Type": "application/json", "X-Signature": hmacSign(secret, conv)},
ExpectedStatus: 200,
ExpectedContent: []string{`"status"`},
})
ifx := `{"transaction_id":"ifx-1","status":"settled"}`
run(t, app, tests.ApiScenario{
Name: "ifx settlement webhook reconciles",
Method: http.MethodPost,
URL: "/v1/bank/webhooks/ifx/settlement",
Body: strings.NewReader(ifx),
Headers: map[string]string{"Content-Type": "application/json", "X-Signature": hmacSign(secret, ifx)},
ExpectedStatus: 200,
ExpectedContent: []string{`"status"`},
})
}
// Sanctions screening blocks a beneficiary create when the compliance service
// says so — covering screenSanctions + the beneficiary-create compliance hook.
func TestSanctionsBlocksBeneficiary(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"result":"blocked"}`))
}))
defer srv.Close()
t.Setenv("COMPLIANCE_SERVICE_URL", srv.URL)
app := newBankApp(t)
id, _ := seedPrincipal(t, app)
acct := primaryAccount(app, id)
col, _ := app.FindCollectionByNameOrId(collections.BeneficiaryCollectionName)
ben := core.NewRecord(col)
ben.Set("account", acct.Id)
ben.Set("name", "Sanctioned Party")
ben.Set("currency", "USD")
ben.Set("country", "IR")
ben.Set("type", "bank")
if err := app.Save(ben); err == nil {
t.Error("expected sanctions screening to block the beneficiary create")
}
}
// Live-mode exchange refuses to price without forexd, rather than falling back
// to sandbox tables.
func TestExchangeLive503WithoutForex(t *testing.T) {
t.Setenv("BANK_SANDBOX", "false")
t.Setenv("FOREX_SERVICE_URL", "")
// A fresh app in live mode; onboarding opens a pending account, so seed a
// principal and hit the public-shaped exchange path via the rate function.
if _, _, err := exchangeRate(t.Context(), "USD", "EUR", 100_00); err == nil {
t.Error("expected live-mode exchange to error without FOREX_SERVICE_URL")
}
}