hardlink/internal/handlers/creditcall_sale_test.go

481 lines
20 KiB
Go

package handlers
import (
"bytes"
"context"
"encoding/json"
"encoding/xml"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/config"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
)
type failedCreditCallBody struct{ closed *bool }
func (b failedCreditCallBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }
func (b failedCreditCallBody) Close() error { *b.closed = true; return nil }
func TestCreditCallConfirmationReadFailureAndHTTPStatus(t *testing.T) {
for _, failFirstRead := range []bool{false, true} {
calls, receipts, firstClosed := 0, 0, false
app := &App{cfg: &config.ConfigRec{}, creditCallReceipt: func(string) { receipts++ }}
app.creditCallTransport = creditCallRoundTrip(func(r *http.Request) (*http.Response, error) {
calls++
if r.URL.Path != "/confirm-transaction/" {
t.Errorf("confirmation path=%s", r.URL.Path)
}
deadline, ok := r.Context().Deadline()
if !ok || time.Until(deadline) < 299*time.Second {
t.Errorf("confirmation call has no fresh 300-second deadline: %v", deadline)
}
var body io.ReadCloser = io.NopCloser(strings.NewReader(chipDNAFixture(t, map[string]string{types.TransactionResult: "Approved"})))
if failFirstRead && calls == 1 {
body = failedCreditCallBody{closed: &firstClosed}
}
// Legacy confirmation ignores HTTP status when the body can be read.
return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: body}, nil
})
outcome := app.executeCreditCallSale(cmstypes.TransactionRec{AmountMinorUnits: "1234"}, func(*http.Client) (creditcall.TransactionResultXML, error) {
return creditcall.TransactionResultXML{Entries: []creditcall.EntryXML{{Key: types.TransactionResult, Value: "Approved"}, {Key: types.Reference, Value: "ref"}}}, nil
})
wantCalls := 1
if failFirstRead {
wantCalls = 2
}
if calls != wantCalls || receipts != 1 || !outcome.Approved || (failFirstRead && !firstClosed) {
t.Errorf("readFailure=%v calls/receipts/approved/closed=%d/%d/%v/%v", failFirstRead, calls, receipts, outcome.Approved, firstClosed)
}
}
}
func TestCreditCallStreamFailureNeverRestartsOrConfirms(t *testing.T) {
calls := 0
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/start-transaction-stream/" {
t.Errorf("unexpected fallback/confirmation %s", r.URL.Path)
}
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"OnlineAuthCompleted\"}\n{partial")
})
app.creditCallReceipt = func(string) { t.Error("invalid upstream stream printed receipt") }
recorder := httptest.NewRecorder()
app.salePayment(recorder, saleRequest(true))
frames := decodePaymentStream(t, recorder.Body)
if calls != 1 || len(frames) != 2 || frames[1].Result == nil || frames[1].Result.Outcome != "error" || frames[1].Result.HTTPStatus != 502 {
t.Errorf("malformed stream calls=%d frames=%+v", calls, frames)
}
}
func TestCreditCallStructuredFieldsExcludeRawData(t *testing.T) {
result := (creditCallSaleOutcome{Approved: true, Payment: creditcall.PaymentResult{Fields: map[string]string{
types.Reference: "ref", types.PanMasked: "1234567890123456", types.CardHash: "hash", types.CardReference: "card-ref",
"PAN": "pan-secret", "CVV": "cvv-secret", "PIN": "pin-secret", "TRACK_DATA": "track-secret", types.ReceiptDataCardholder: "receipt-secret",
}}}).streamResult()
body, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
for _, forbidden := range []string{"1234567890123456", "pan-secret", "cvv-secret", "pin-secret", "track-secret", "receipt-secret", "/successful", "/unsuccessful"} {
if bytes.Contains(body, []byte(forbidden)) {
t.Errorf("structured result exposed %q", forbidden)
}
}
if result.CardHash != "hash" || result.CardReference != "card-ref" || result.TransactionReference != "ref" {
t.Errorf("required identifiers missing: %+v", result)
}
}
type blockedPaymentWriter struct {
*httptest.ResponseRecorder
started chan struct{}
release chan struct{}
once sync.Once
}
func (w *blockedPaymentWriter) Write(data []byte) (int, error) {
w.once.Do(func() { close(w.started) })
<-w.release
return w.ResponseRecorder.Write(data)
}
func TestCreditCallBackpressureCannotBlockFinalizationOrLoseFinal(t *testing.T) {
nativeRelease, receiptPrinted := make(chan struct{}), make(chan struct{})
var nativeOnce, deliveryOnce sync.Once
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/confirm-transaction/" {
io.WriteString(w, chipDNAFixture(t, map[string]string{types.TransactionResult: "Approved"}))
return
}
line := "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"CardRequested\"}\n"
io.WriteString(w, line)
w.(http.Flusher).Flush()
<-nativeRelease
for i := 0; i < 1000; i++ {
io.WriteString(w, line)
}
io.WriteString(w, "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"Approved\",\"REFERENCE\":\"ref\"}}\n")
})
app.creditCallReceipt = func(string) { close(receiptPrinted) }
writer := &blockedPaymentWriter{ResponseRecorder: httptest.NewRecorder(), started: make(chan struct{}), release: make(chan struct{})}
t.Cleanup(func() {
nativeOnce.Do(func() { close(nativeRelease) })
deliveryOnce.Do(func() { close(writer.release) })
})
done := make(chan struct{})
go func() { app.salePayment(writer, saleRequest(true)); close(done) }()
select {
case <-writer.started:
case <-time.After(5 * time.Second):
t.Fatal("progress was not live")
}
nativeOnce.Do(func() { close(nativeRelease) })
select {
case <-receiptPrinted:
case <-time.After(5 * time.Second):
t.Fatal("blocked writer stopped financial finalization")
}
deliveryOnce.Do(func() { close(writer.release) })
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("stream did not deliver final")
}
frames := decodePaymentStream(t, writer.Body)
if len(frames) != 130 || frames[len(frames)-1].Result == nil || frames[len(frames)-1].Result.Outcome != "approved" {
t.Errorf("backpressure frames=%d, last=%+v, want 129 statuses and final approval", len(frames), frames[len(frames)-1])
}
}
type creditCallRoundTrip func(*http.Request) (*http.Response, error)
func (f creditCallRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func chipDNAFixture(t *testing.T, fields map[string]string) string {
t.Helper()
var result creditcall.TransactionResultXML
for key, value := range fields {
result.Entries = append(result.Entries, creditcall.EntryXML{Key: key, Value: value})
}
data, err := xml.Marshal(result)
if err != nil {
t.Fatal(err)
}
return string(data)
}
func newCreditCallTestApp(t *testing.T, handler http.HandlerFunc) *App {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
target, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
return &App{isPayment: true, creditCallStreamEnabled: true, cfg: &config.ConfigRec{TimeoutSeconds: 1},
creditCallTransport: creditCallRoundTrip(func(r *http.Request) (*http.Response, error) {
clone := r.Clone(r.Context())
clone.URL.Scheme, clone.URL.Host = target.Scheme, target.Host
return server.Client().Transport.RoundTrip(clone)
}),
}
}
func saleRequest(stream bool) *http.Request {
if stream {
r := httptest.NewRequest(http.MethodPost, "/api/payment/sale", strings.NewReader(`{"amount":1234}`))
r.Header.Set("Content-Type", "application/json")
return r
}
r := httptest.NewRequest(http.MethodPost, "/takepayment", strings.NewReader(`<TransactionPayload><Amount>1234</Amount><TransactionType>Sale</TransactionType></TransactionPayload>`))
r.Header.Set("Content-Type", "text/xml")
return r
}
func TestCreditCallLegacyAndStreamingSaleParity(t *testing.T) {
for _, test := range []struct {
name, startResult, confirmation string
confirms int
approved bool
receipt string
}{
{"approved", "Approved", "Approved", 1, true, "confirm receipt"},
{"declined", "Declined", "", 0, false, "start receipt"},
{"cancelled", "Cancelled", "", 0, false, "start receipt"},
{"timeout", "TIMEOUT", "", 0, false, "start receipt"},
{"confirmation declined", "Approved", "Declined", 1, false, "confirm receipt"},
{"confirmation omissions", "Approved", "omitted", 1, true, "start receipt"},
{"malformed confirmation", "Approved", "malformed", 1, true, "start receipt"},
} {
t.Run(test.name, func(t *testing.T) {
fields := map[string]string{types.TransactionResult: test.startResult, types.Reference: "native-ref",
types.CardType: "Visa", types.PanMasked: "************1234", types.ExpiryDate: "1228",
types.CardHash: "hash", types.CardReference: "card-ref", types.ReceiptDataCardholder: "start receipt"}
var legacy cmstypes.ResponseRec
for _, streaming := range []bool{false, true} {
var starts, confirms atomic.Int32
var receipts []string
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/start-transaction/":
starts.Add(1)
io.WriteString(w, chipDNAFixture(t, fields))
case "/start-transaction-stream/":
starts.Add(1)
var input struct{ Amount, TransactionType string }
if err := json.NewDecoder(r.Body).Decode(&input); err != nil || input.Amount != "1234" || input.TransactionType != "Sale" {
t.Errorf("stream start input = %+v, error %v", input, err)
}
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"CardRequested\"}\n")
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"OnlineAuthCompleted\"}\n")
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"Approved\"}\n")
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": fields})
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": fields})
case "/confirm-transaction/":
confirms.Add(1)
var input creditcall.ConfirmTransactionRequest
if err := xml.NewDecoder(r.Body).Decode(&input); err != nil || input.Amount != "1234" || input.Reference != "native-ref" {
t.Errorf("confirmation input = %+v, error %v", input, err)
}
switch test.confirmation {
case "omitted":
io.WriteString(w, "<TransactionResult/>")
case "malformed":
io.WriteString(w, "<TransactionResult><Entry>")
default:
io.WriteString(w, chipDNAFixture(t, map[string]string{types.TransactionResult: test.confirmation, types.ReceiptDataCardholder: "confirm receipt"}))
}
default:
t.Errorf("unexpected ChipDNA path %q", r.URL.Path)
}
})
app.creditCallReceipt = func(receipt string) { receipts = append(receipts, receipt) }
if got := app.creditCallClient().Timeout; got != 300*time.Second {
t.Fatalf("per-call timeout = %v, want 300s", got)
}
recorder := httptest.NewRecorder()
if streaming {
app.salePayment(recorder, saleRequest(true))
} else {
app.takePayment(recorder, saleRequest(false))
}
if starts.Load() != 1 || int(confirms.Load()) != test.confirms {
t.Errorf("stream=%v starts/confirms=%d/%d, want 1/%d", streaming, starts.Load(), confirms.Load(), test.confirms)
}
if !reflect.DeepEqual(receipts, []string{test.receipt}) {
t.Errorf("stream=%v receipts=%v, want [%s]", streaming, receipts, test.receipt)
}
if !streaming {
if err := json.Unmarshal(recorder.Body.Bytes(), &legacy); err != nil {
t.Fatal(err)
}
if strings.HasPrefix(legacy.Data, "/successful") != test.approved {
t.Errorf("legacy result = %+v, approved want %v", legacy, test.approved)
}
continue
}
frames := decodePaymentStream(t, recorder.Body)
if len(frames) != 3 || frames[0].Code != paymentstatus.PresentCard || frames[1].Code != paymentstatus.PleaseWait || frames[2].Result == nil {
t.Fatalf("frames = %+v, want two ordered hints and one final", frames)
}
final := frames[2].Result
if final.HTTPStatus != http.StatusOK || final.Status != legacy.Status || (final.Outcome == "approved") != test.approved {
t.Errorf("structured final = %+v, legacy = %+v", final, legacy)
}
parsed, err := url.Parse(legacy.Data)
if err != nil {
t.Fatal(err)
}
if test.approved {
if final.TransactionReference != "native-ref" || final.CardType != "Visa" || final.MaskedCardNumber != "************1234" || final.CardHash != "hash" || final.CardReference != "card-ref" || final.ExpiryDate != "1228" {
t.Errorf("retained structured fields = %+v", final)
}
} else if final.Message != parsed.Query().Get("Description") {
t.Errorf("structured message = %q, legacy description = %q", final.Message, parsed.Query().Get("Description"))
}
if frames[2].Response != nil {
t.Error("CreditCall stream included legacy response")
}
}
})
}
}
func TestCreditCallConfirmationRetryAndFailureParity(t *testing.T) {
for _, streaming := range []bool{false, true} {
var attempts int
var times []time.Time
app := &App{isPayment: true, creditCallStreamEnabled: true, cfg: &config.ConfigRec{}}
app.creditCallReceipt = func(string) { t.Error("transport-failed confirmation must not print a receipt") }
app.creditCallTransport = creditCallRoundTrip(func(r *http.Request) (*http.Response, error) {
if r.Context().Err() != nil {
t.Error("transaction context was cancelled")
}
if r.URL.Path == "/confirm-transaction/" {
attempts++
times = append(times, time.Now())
return nil, errors.New("test transport failure")
}
body := chipDNAFixture(t, map[string]string{types.TransactionResult: "Approved", types.Reference: "native-ref"})
if streaming {
body = "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"Approved\",\"REFERENCE\":\"native-ref\"}}\n"
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
})
recorder := httptest.NewRecorder()
if streaming {
app.salePayment(recorder, saleRequest(true))
} else {
app.takePayment(recorder, saleRequest(false))
}
if attempts != 2 {
t.Fatalf("stream=%v confirms=%d, want 2", streaming, attempts)
}
if times[1].Sub(times[0]) < 2*time.Second {
t.Errorf("retry delay=%v, want at least 2s", times[1].Sub(times[0]))
}
if streaming {
frames := decodePaymentStream(t, recorder.Body)
if len(frames) != 1 || frames[0].Result.HTTPStatus != 502 || frames[0].Result.Status.Code != 500 || frames[0].Result.Message != "Transaction error" {
t.Fatalf("confirmation failure frames=%+v", frames)
}
} else if recorder.Code != 502 {
t.Errorf("legacy confirm failure HTTP=%d, want 502", recorder.Code)
}
}
}
func TestCreditCallLegacyMalformedStartAndPreauth(t *testing.T) {
for _, test := range []struct{ name, path, result, transactionType string }{
{"malformed sale", "/takepayment", "malformed", ""},
{"approved account verification", "/takepreauth", "Approved", types.AccountVerificationType},
{"declined preauth", "/takepreauth", "Declined", "Sale"},
{"malformed preauth", "/takepreauth", "malformed", ""},
} {
t.Run(test.name, func(t *testing.T) {
calls, prints := 0, 0
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/start-transaction/" {
t.Errorf("legacy/preauth path=%s", r.URL.Path)
}
if test.result == "malformed" {
io.WriteString(w, "<TransactionResult><Entry>")
return
}
io.WriteString(w, chipDNAFixture(t, map[string]string{types.TransactionResult: test.result, types.TransactionType: test.transactionType}))
})
app.creditCallReceipt = func(string) { prints++ }
recorder := httptest.NewRecorder()
request := saleRequest(false)
if test.path == "/takepreauth" {
app.takePreauthorization(recorder, request)
} else {
app.takePayment(recorder, request)
}
var response cmstypes.ResponseRec
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if recorder.Code != 200 || calls != 1 || prints != 1 {
t.Errorf("legacy HTTP/calls/receipts=%d/%d/%d", recorder.Code, calls, prints)
}
if test.result == "malformed" && (response.Status.Code != 0 || response.Data != "/unsuccessful?Description=&MsgType=") {
t.Errorf("malformed legacy result=%+v", response)
}
if test.result == "Approved" && !strings.HasPrefix(response.Data, "/successful?") {
t.Errorf("approved preauth=%+v", response)
}
})
}
}
func TestChipDNAStreamRejectsIncompleteOrInvalidFrames(t *testing.T) {
for _, body := range []string{"", "{", "{}\n", "<xml/>\n", "{\"type\":\"result\"}\n", "{\"type\":\"result\",\"result\":{}}\n", "{\"type\":\"error\",\"error\":\"sensitive diagnostic\"}\n", "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"Approved\"}}", "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"Approved\"}\n"} {
client := &http.Client{Transport: creditCallRoundTrip(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}, nil
})}
_, err := callChipDNAStream(client, 1234, func(code string) { t.Errorf("invalid stream produced status %q", code) })
if err == nil {
t.Errorf("callChipDNAStream(%q) succeeded, want error", body)
}
if err != nil && strings.Contains(err.Error(), "sensitive diagnostic") {
t.Error("raw diagnostic leaked")
}
}
}
type disconnectedPaymentWriter struct {
header http.Header
once sync.Once
failed chan struct{}
writes atomic.Int32
}
func (w *disconnectedPaymentWriter) Header() http.Header { return w.header }
func (w *disconnectedPaymentWriter) WriteHeader(int) {}
func (w *disconnectedPaymentWriter) Flush() {}
func (w *disconnectedPaymentWriter) Write([]byte) (int, error) {
w.writes.Add(1)
w.once.Do(func() { close(w.failed) })
return 0, io.ErrClosedPipe
}
func TestCreditCallDisconnectStillConfirmsAndPrints(t *testing.T) {
release := make(chan struct{})
var confirms atomic.Int32
var receipts atomic.Int32
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/confirm-transaction/" {
confirms.Add(1)
io.WriteString(w, chipDNAFixture(t, map[string]string{types.TransactionResult: "Approved", types.ReceiptDataCardholder: "receipt"}))
return
}
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"CardRequested\"}\n")
w.(http.Flusher).Flush()
<-release
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"OnlineAuthCompleted\"}\n")
io.WriteString(w, "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"Approved\",\"REFERENCE\":\"ref\"}}\n")
})
app.creditCallReceipt = func(receipt string) {
if receipt != "receipt" {
t.Errorf("receipt=%q", receipt)
}
receipts.Add(1)
}
writer := &disconnectedPaymentWriter{header: make(http.Header), failed: make(chan struct{})}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() { app.salePayment(writer, saleRequest(true).WithContext(ctx)); close(done) }()
select {
case <-writer.failed:
case <-time.After(5 * time.Second):
close(release)
t.Fatal("no live progress before final result")
}
cancel()
close(release)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("finalization stopped after disconnect")
}
if confirms.Load() != 1 || receipts.Load() != 1 || writer.writes.Load() != 1 {
t.Errorf("disconnect confirms/receipts/writes=%d/%d/%d, want 1/1/1", confirms.Load(), receipts.Load(), writer.writes.Load())
}
}