Files
bank/hooks/email_test.go
zeekayandHanzo Dev 92461c82b6 a notification says the amount a person sent, not the one that is stored
Everything is held in minor units, and the transaction notice printed the
stored number: a $250.00 transfer told the customer it was 25000, and a
whole bitcoin told them 1000000. Rendering is now done where the number of
decimal places is already known, beside DecimalsFor, in integer arithmetic
— money put through a float is money rounded by whatever the float held.
Yen has no minor unit and must not grow a decimal point; a negative amount
keeps its sign, since truncation toward zero makes both halves negative
and a naive join prints a loss as a gain.

The two senders looked up account, owner and address separately and logged
separately. That is one lookup and one send, so it is written once. What
each says is now a function of the record alone, which is what makes it
checkable without sending anything — and the KYC test was silently
skipping over a collection the harness never created, so it had never run
at all.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-27 07:33:38 -07:00

164 lines
5.9 KiB
Go

package hooks
import (
"strings"
"testing"
"github.com/hanzoai/base/core"
"github.com/luxfi/bank/collections"
)
// With no SMTP host the notification is skipped, not failed. A bank that cannot
// mail a receipt has still moved the money, and treating that as an error would
// make a best-effort notice look like a failed transaction.
func TestNoSMTPConfiguredSkipsQuietly(t *testing.T) {
t.Setenv("SMTP_HOST", "")
if err := sendEmail("[email protected]", "Transaction Completed", "body"); err != nil {
t.Fatalf("sendEmail with no host: %v, want nil", err)
}
}
// The message is assembled by hand, so a CR or LF in a header value closes that
// header and opens whatever follows — a Bcc to somewhere else, a replaced From.
// Nothing sends user-written text through here today, which is exactly why the
// refusal belongs in the helper rather than in each caller's head.
func TestALineBreakInAHeaderIsRefused(t *testing.T) {
t.Setenv("SMTP_HOST", "smtp.invalid")
t.Setenv("SMTP_PORT", "587")
for _, tc := range []struct{ name, to, subject string }{
{"bcc smuggled through the recipient", "[email protected]\r\nBcc: [email protected]", "Receipt"},
{"newline alone in the recipient", "[email protected]\nBcc: [email protected]", "Receipt"},
{"header smuggled through the subject", "[email protected]", "Receipt\r\nBcc: [email protected]"},
{"carriage return alone in the subject", "[email protected]", "Receipt\rX-Spoof: 1"},
} {
t.Run(tc.name, func(t *testing.T) {
err := sendEmail(tc.to, tc.subject, "body")
if err == nil {
t.Fatal("accepted a header value carrying a line break")
}
if !strings.Contains(err.Error(), "forge a header") {
t.Fatalf("err = %v, want it to name the forged header", err)
}
})
}
}
// The body is exempt: line breaks are what a body is made of, and it sits after
// the blank line where no header is being parsed. Refusing them would break
// every multi-line notice.
//
// SMTP_HOST points at a name that does not resolve, so this gets past the
// header check and fails at the send — which is the proof it was not refused
// earlier.
func TestTheBodyMayContainLineBreaks(t *testing.T) {
t.Setenv("SMTP_HOST", "smtp.invalid.example")
t.Setenv("SMTP_PORT", "587")
err := sendEmail("[email protected]", "Receipt", "Line one\r\nLine two\r\n\r\nRegards")
if err != nil && strings.Contains(err.Error(), "forge a header") {
t.Fatalf("a multi-line body was refused as a forged header: %v", err)
}
}
// The recipient guard is not belt-and-braces over a library that already checks.
// Measured against jordan-wright/email directly: a line break in the SUBJECT is
// Q-encoded and inert, and one in the RECIPIENT is obeyed — the address splits
// and a real Bcc header lands in the message. This pins the half that matters,
// so removing the guard on the argument that "the library handles it" fails
// here.
func TestTheRecipientIsWhereTheLibraryWouldLetAHeaderThrough(t *testing.T) {
t.Setenv("SMTP_HOST", "smtp.invalid.example")
err := sendEmail("[email protected]\r\nBcc: [email protected]", "Receipt", "body")
if err == nil {
t.Fatal("a recipient carrying a line break was accepted")
}
if !strings.Contains(err.Error(), "forge a header") {
t.Fatalf("err = %v — refused, but not by the guard; the library does not refuse this", err)
}
}
// What a customer is told their money was. The amount lives in minor units, so
// a notification that prints the stored number tells someone their $250.00
// transfer was 25000 — and for crypto it is off by a million.
func TestATransactionNoticeSaysTheAmountAPersonSent(t *testing.T) {
app := limitApp(t)
col, err := app.FindCollectionByNameOrId(collections.TransactionCollectionName)
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
kind, currency string
amount int64
want, wrong string
}{
{"transfer", "USD", 25000, "USD 250.00", "25000"},
{"deposit", "EUR", 1, "EUR 0.01", " 1 "},
{"withdrawal", "BTC", 1000000, "BTC 1.000000", "1000000"},
{"transfer", "JPY", 25000, "JPY 25000", "250.00"},
} {
r := core.NewRecord(col)
r.Set("type", tc.kind)
r.Set("currency", tc.currency)
r.Set("amount", tc.amount)
subject, body := transactionNotice(r)
if subject != "Transaction Completed" {
t.Errorf("subject = %q", subject)
}
if !strings.Contains(body, tc.want) {
t.Errorf("a %d %s %s reads as %q, and should say %q",
tc.amount, tc.currency, tc.kind, body, tc.want)
}
if strings.Contains(body, tc.wrong) {
t.Errorf("a %d %s %s still shows %q: %q", tc.amount, tc.currency, tc.kind, tc.wrong, body)
}
if !strings.Contains(body, tc.kind) {
t.Errorf("the notice does not say what kind of transaction it was: %q", body)
}
}
}
// The KYC decision has to be in the message, since approved and rejected are
// the same mail otherwise.
func TestADocumentNoticeCarriesTheDecision(t *testing.T) {
app := limitApp(t)
if err := collections.EnsureDocumentCollection(app); err != nil {
t.Fatalf("ensure documents: %v", err)
}
col, err := app.FindCollectionByNameOrId(collections.DocumentCollectionName)
if err != nil {
t.Fatal(err)
}
r := core.NewRecord(col)
r.Set("type", "passport")
for _, status := range []string{"approved", "rejected"} {
subject, body := documentNotice(r, status)
if !strings.Contains(subject, status) {
t.Errorf("subject %q does not carry the decision %q", subject, status)
}
if !strings.Contains(body, status) || !strings.Contains(body, "passport") {
t.Errorf("body %q does not say the passport was %s", body, status)
}
}
}
// Nobody to tell is not a failure. A record whose account or owner has gone
// leaves no address, and the notification stops there rather than raising.
func TestNoOwnerLeavesNobodyToTell(t *testing.T) {
app := limitApp(t)
col, err := app.FindCollectionByNameOrId(collections.TransactionCollectionName)
if err != nil {
t.Fatal(err)
}
r := core.NewRecord(col)
r.Set("account", "nosuchaccountid")
if to := recipient(app, r); to != "" {
t.Errorf("recipient of a transaction with no account = %q, want none", to)
}
}