394 lines
16 KiB
Go
394 lines
16 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"database/sql/driver"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
|
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
|
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
|
|
)
|
|
|
|
type preauthTestConnector struct {
|
|
inserts chan []driver.NamedValue
|
|
count atomic.Int32
|
|
}
|
|
|
|
func (c *preauthTestConnector) Connect(context.Context) (driver.Conn, error) {
|
|
return &preauthTestConn{c}, nil
|
|
}
|
|
func (c *preauthTestConnector) Driver() driver.Driver { return preauthTestDriver{c} }
|
|
|
|
type preauthTestDriver struct{ c *preauthTestConnector }
|
|
|
|
func (d preauthTestDriver) Open(string) (driver.Conn, error) { return &preauthTestConn{d.c}, nil }
|
|
|
|
type preauthTestConn struct{ c *preauthTestConnector }
|
|
|
|
func (*preauthTestConn) Prepare(string) (driver.Stmt, error) {
|
|
return nil, errors.New("unexpected prepare")
|
|
}
|
|
func (*preauthTestConn) Close() error { return nil }
|
|
func (*preauthTestConn) Begin() (driver.Tx, error) { return nil, errors.New("unexpected begin") }
|
|
func (*preauthTestConn) Ping(context.Context) error { return nil }
|
|
func (c *preauthTestConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
if !strings.Contains(query, "INSERT INTO dbo.Preauthorizations") {
|
|
return nil, errors.New("unexpected SQL")
|
|
}
|
|
c.c.count.Add(1)
|
|
c.c.inserts <- append([]driver.NamedValue(nil), args...)
|
|
return driver.RowsAffected(1), nil
|
|
}
|
|
func preauthTestDatabase(t *testing.T, app *App) *preauthTestConnector {
|
|
t.Helper()
|
|
c := &preauthTestConnector{inserts: make(chan []driver.NamedValue, 4)}
|
|
app.db = sql.OpenDB(c)
|
|
app.cfg.LogDir = t.TempDir()
|
|
t.Cleanup(func() { app.db.Close() })
|
|
return c
|
|
}
|
|
func waitPreauthInsert(t *testing.T, c *preauthTestConnector) map[string]any {
|
|
t.Helper()
|
|
select {
|
|
case args := <-c.inserts:
|
|
result := map[string]any{}
|
|
for _, arg := range args {
|
|
result[arg.Name] = arg.Value
|
|
}
|
|
return result
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("preauth persistence did not reach SQL")
|
|
}
|
|
return nil
|
|
}
|
|
func preauthLegacyRequest(amount, kind, checkout string) *http.Request {
|
|
body, _ := xml.Marshal(cmstypes.TransactionRec{AmountMinorUnits: amount, TransactionType: kind, CheckoutDate: checkout})
|
|
r := httptest.NewRequest(http.MethodPost, "/takepreauth", strings.NewReader(string(body)))
|
|
r.Header.Set("Content-Type", "text/xml")
|
|
return r
|
|
}
|
|
|
|
func TestCreditCallPreauthLegacyCharacterization(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, amount, requestType, result, resultType, total string
|
|
save, approved bool
|
|
}{
|
|
{"positive", "12000", "Sale", "APPROVED", "SALE", "3100", true, true},
|
|
{"declined", "12000", "Sale", "DECLINED", "SALE", "", false, false},
|
|
{"verification", "", "AccountVerification", "APPROVED", "ACCOUNT VERIFICATION", "", false, true},
|
|
{"verification failure", "", "AccountVerification", "DECLINED", "ACCOUNT VERIFICATION", "", false, false},
|
|
{"unexpected type", "12000", "Sale", "APPROVED", "Refund", "", false, false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var starts, prints int
|
|
checkout := ""
|
|
if tc.amount != "" {
|
|
checkout = "2026-09-11 00:00:00 +0000"
|
|
}
|
|
fields := map[string]string{types.TransactionResult: tc.result, types.TransactionType: tc.resultType, types.Reference: "preauth-ref", types.PanMasked: "************1133", types.CardType: "Visa", types.ExpiryDate: "1228", types.CardHash: "card-hash", types.CardReference: "card-reference", types.ReceiptDataCardholder: "receipt"}
|
|
if tc.total != "" {
|
|
fields[types.TotalAmount] = tc.total
|
|
}
|
|
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
starts++
|
|
if r.URL.Path != "/start-transaction/" {
|
|
t.Errorf("PREAUTH upstream path=%s, want start only", r.URL.Path)
|
|
}
|
|
var input cmstypes.TransactionRec
|
|
if err := xml.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
t.Error(err)
|
|
}
|
|
if input.AmountMinorUnits != tc.amount || input.TransactionType != tc.requestType || input.CheckoutDate != checkout {
|
|
t.Errorf("PREAUTH input=%+v, want amount=%q type=%q checkout=%q", input, tc.amount, tc.requestType, checkout)
|
|
}
|
|
// Legacy ignores upstream HTTP status when XML contains a result.
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
io.WriteString(w, chipDNAFixture(t, fields))
|
|
})
|
|
c := preauthTestDatabase(t, app)
|
|
app.creditCallReceipt = func(receipt string) {
|
|
prints++
|
|
if receipt != "receipt" {
|
|
t.Errorf("receipt=%q, want receipt", receipt)
|
|
}
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
app.takePreauthorization(recorder, preauthLegacyRequest(tc.amount, tc.requestType, checkout))
|
|
var response cmstypes.ResponseRec
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if recorder.Code != 200 || response.Status.Code != 200 || starts != 1 || prints != 1 || strings.HasPrefix(response.Data, "/successful?") != tc.approved {
|
|
t.Errorf("PREAUTH response=%+v HTTP=%d starts=%d prints=%d, want approved=%t one start/receipt", response, recorder.Code, starts, prints, tc.approved)
|
|
}
|
|
if tc.save {
|
|
got := waitPreauthInsert(t, c)
|
|
if got["TotalMinorUnits"] != "3100" || got["TxnReference"] != "preauth-ref" {
|
|
t.Errorf("persisted=%v, want provider amount/reference", got)
|
|
}
|
|
departure := time.Date(2026, 9, 11, 0, 0, 0, 0, time.Local).UTC()
|
|
if got["DepartureDate"] != departure || got["ReleaseDate"] != departure.Add(48*time.Hour) {
|
|
t.Errorf("persisted dates=%v, want existing local midnight plus 48 hours", got)
|
|
}
|
|
} else if c.count.Load() != 0 {
|
|
t.Error("non-persisting result inserted SQL")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func preauthStreamRequest(amount, kind, checkout string) *http.Request {
|
|
body, _ := json.Marshal(creditCallPreauthRequest{Amount: amount, TransactionType: kind, CheckoutDate: checkout})
|
|
r := httptest.NewRequest(http.MethodPost, "/api/payment/preauth", strings.NewReader(string(body)))
|
|
r.Header.Set("Content-Type", "application/json")
|
|
return r
|
|
}
|
|
|
|
func TestCreditCallPreauthStreamingParity(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, result, kind, total string
|
|
approved, persist bool
|
|
}{
|
|
{"monetary", "APPROVED", "SALE", "3100", true, true},
|
|
{"verification", "APPROVED", "ACCOUNT VERIFICATION", "", true, false},
|
|
{"declined", "DECLINED", "SALE", "", false, false},
|
|
{"verification failed", "DECLINED", "ACCOUNT VERIFICATION", "", false, false},
|
|
{"unknown approved type", "APPROVED", "AccountVerification", "", false, false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fields := map[string]string{types.TransactionResult: tc.result, types.TransactionType: tc.kind, types.Reference: "preauth-ref", types.PanMasked: "************1133", types.CardType: "Visa", types.ExpiryDate: "1228", types.CardHash: "hash", types.CardReference: "cardref", types.ReceiptDataCardholder: "receipt"}
|
|
if tc.total != "" {
|
|
fields[types.TotalAmount] = tc.total
|
|
}
|
|
amount, kind, date := "12000", "Sale", "2026-09-11 00:00:00 +0000"
|
|
if strings.Contains(tc.name, "verification") {
|
|
amount, kind, date = "", "AccountVerification", ""
|
|
}
|
|
var legacy cmstypes.ResponseRec
|
|
for _, stream := range []bool{false, true} {
|
|
calls, prints := 0, 0
|
|
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
calls++
|
|
if stream {
|
|
if r.URL.Path != "/start-transaction-stream/" {
|
|
t.Errorf("stream called %s, want one generic start", r.URL.Path)
|
|
}
|
|
var input creditCallPreauthRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
t.Error(err)
|
|
}
|
|
if input.Amount != amount || input.TransactionType != kind {
|
|
t.Errorf("upstream input=%+v, want %q/%q", input, amount, kind)
|
|
}
|
|
for _, event := range []struct{ source, value string }{{"UPDATE", "CardRequested"}, {"CARD_STATUS", "Inserted"}, {"UPDATE", "CardRemovalRequested"}, {"CARD_STATUS", "Removed"}, {"UPDATE", "CardRequested"}, {"UPDATE", "CardRemovalEnforced"}, {"UPDATE", "PinEntryStarted"}, {"UPDATE", "OnlineAuthCompleted"}} {
|
|
json.NewEncoder(w).Encode(map[string]any{"type": "status", "source": event.source, "value": event.value})
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": fields})
|
|
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": fields})
|
|
} else {
|
|
io.WriteString(w, chipDNAFixture(t, fields))
|
|
}
|
|
})
|
|
c := preauthTestDatabase(t, app)
|
|
app.creditCallReceipt = func(receipt string) {
|
|
prints++
|
|
if receipt != "receipt" {
|
|
t.Errorf("receipt=%q, want retained receipt", receipt)
|
|
}
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
if stream {
|
|
app.streamCreditCallPreauth(recorder, preauthStreamRequest(amount, kind, date))
|
|
frames := decodePaymentStream(t, recorder.Body)
|
|
var statuses []string
|
|
for _, frame := range frames {
|
|
if frame.Type == "status" {
|
|
statuses = append(statuses, frame.Code)
|
|
}
|
|
}
|
|
want := []string{paymentstatus.PresentCard, paymentstatus.DoNotRemoveCard, paymentstatus.RemoveCard, paymentstatus.PresentCard, paymentstatus.RemoveCard, paymentstatus.EnterPIN, paymentstatus.PleaseWait}
|
|
if !reflect.DeepEqual(statuses, want) {
|
|
t.Errorf("preauth statuses=%v, want %v", statuses, want)
|
|
}
|
|
final := frames[len(frames)-1].Result
|
|
if final == nil {
|
|
t.Fatal("missing structured final")
|
|
}
|
|
if (final.Outcome == "approved") != tc.approved || final.Status != legacy.Status || final.HTTPStatus != 200 {
|
|
t.Errorf("stream final=%+v, legacy=%+v", final, legacy)
|
|
}
|
|
if tc.approved && (final.TransactionReference != "preauth-ref" || final.CardType != "Visa" || final.MaskedCardNumber != "************1133" || final.ExpiryDate != "1228" || final.CardHash != "hash" || final.CardReference != "cardref") {
|
|
t.Errorf("preauth fields lost: %+v", final)
|
|
}
|
|
if len(frames) != len(want)+1 {
|
|
t.Errorf("frames=%d, want one final", len(frames))
|
|
}
|
|
} else {
|
|
app.takePreauthorization(recorder, preauthLegacyRequest(amount, kind, date))
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &legacy); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if calls != 1 || prints != 1 {
|
|
t.Errorf("calls/prints=%d/%d, want 1/1", calls, prints)
|
|
}
|
|
if tc.persist {
|
|
got := waitPreauthInsert(t, c)
|
|
if got["TotalMinorUnits"] != tc.total {
|
|
t.Errorf("SQL amount=%v, want provider %q", got["TotalMinorUnits"], tc.total)
|
|
}
|
|
} else if c.count.Load() != 0 {
|
|
t.Error("verification/failure persisted")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreditCallInboundCancellationAfterDispatch(t *testing.T) {
|
|
for _, preauth := range []bool{false, true} {
|
|
t.Run(map[bool]string{false: "sale", true: "preauth"}[preauth], func(t *testing.T) {
|
|
dispatched := make(chan struct{})
|
|
release := make(chan struct{})
|
|
var starts, confirms, 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
|
|
}
|
|
starts.Add(1)
|
|
close(dispatched)
|
|
<-release
|
|
if r.Context().Err() != nil {
|
|
t.Errorf("financial upstream was cancelled: %v", r.Context().Err())
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": map[string]string{types.TransactionResult: "APPROVED", types.TransactionType: "SALE", types.TotalAmount: "3100", types.Reference: "delayed-ref", types.ReceiptDataCardholder: "receipt"}})
|
|
})
|
|
c := preauthTestDatabase(t, app)
|
|
app.creditCallReceipt = func(receipt string) {
|
|
if receipt != "receipt" {
|
|
t.Errorf("receipt=%q", receipt)
|
|
}
|
|
receipts.Add(1)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
recorder := httptest.NewRecorder()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
if preauth {
|
|
app.streamCreditCallPreauth(recorder, preauthStreamRequest("12000", "Sale", "2026-09-11 00:00:00 +0000").WithContext(ctx))
|
|
} else {
|
|
app.streamCreditCallSale(recorder, saleRequest(true).WithContext(ctx))
|
|
}
|
|
}()
|
|
select {
|
|
case <-dispatched:
|
|
case <-time.After(3 * time.Second):
|
|
close(release)
|
|
t.Fatal("no upstream dispatch")
|
|
}
|
|
cancel() // Exact acceptance boundary: upstream has observed StartTransaction.
|
|
close(release)
|
|
select {
|
|
case <-done:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("cancelled inbound request stopped finalization")
|
|
}
|
|
wantConfirms := int32(1)
|
|
if preauth {
|
|
wantConfirms = 0
|
|
got := waitPreauthInsert(t, c)
|
|
if got["TotalMinorUnits"] != "3100" {
|
|
t.Errorf("delayed persistence=%v", got)
|
|
}
|
|
if c.count.Load() != 1 {
|
|
t.Errorf("delayed persistence count=%d, want exactly one", c.count.Load())
|
|
}
|
|
}
|
|
if starts.Load() != 1 || receipts.Load() != 1 || confirms.Load() != wantConfirms {
|
|
t.Errorf("starts/receipts/confirms=%d/%d/%d, want 1/1/%d", starts.Load(), receipts.Load(), confirms.Load(), wantConfirms)
|
|
}
|
|
if recorder.Body.Len() != 0 {
|
|
t.Errorf("cancelled delivery wrote %s", recorder.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreditCallPreauthMissingAmountNeverSynthesized(t *testing.T) {
|
|
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": map[string]string{types.TransactionResult: "APPROVED", types.TransactionType: "SALE", types.Reference: "missing-amount"}})
|
|
})
|
|
c := preauthTestDatabase(t, app)
|
|
app.creditCallReceipt = func(string) {}
|
|
recorder := httptest.NewRecorder()
|
|
app.streamCreditCallPreauth(recorder, preauthStreamRequest("9999", "Sale", "2026-09-11 00:00:00 +0000"))
|
|
frames := decodePaymentStream(t, recorder.Body)
|
|
if frames[0].Result.Outcome != "approved" {
|
|
t.Fatalf("missing amount changed approval: %+v", frames[0].Result)
|
|
}
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for {
|
|
data, err := os.ReadFile(app.spoolPath())
|
|
var record preauthSpoolRecord
|
|
if err == nil && json.Unmarshal(data, &record) == nil {
|
|
if _, ok := record.Fields[types.TotalAmount]; ok {
|
|
t.Errorf("spool fabricated amount: %v", record.Fields)
|
|
}
|
|
if record.CheckoutDate != "2026-09-11 00:00:00 +0000" {
|
|
t.Errorf("spool checkout=%q", record.CheckoutDate)
|
|
}
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("missing provider amount did not retain existing spool fallback")
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if c.count.Load() != 0 {
|
|
t.Error("SQL inserted fabricated amount")
|
|
}
|
|
}
|
|
|
|
func TestCreditCallPreauthStreamFailureAndCancelledBeforeDispatch(t *testing.T) {
|
|
for _, body := range []string{"", "{", "{}\n", "<xml/>\n", "{\"type\":\"result\",\"result\":{}}\n", "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"APPROVED\"}}"} {
|
|
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, body) })
|
|
calls := 0
|
|
transport := app.creditCallTransport
|
|
app.creditCallTransport = creditCallRoundTrip(func(r *http.Request) (*http.Response, error) { calls++; return transport.RoundTrip(r) })
|
|
app.creditCallReceipt = func(string) { t.Error("malformed stream printed receipt") }
|
|
recorder := httptest.NewRecorder()
|
|
app.streamCreditCallPreauth(recorder, preauthStreamRequest("", "AccountVerification", ""))
|
|
frames := decodePaymentStream(t, recorder.Body)
|
|
if calls != 1 || len(frames) != 1 || frames[0].Result.Outcome != "error" || frames[0].Result.HTTPStatus != 502 {
|
|
t.Errorf("invalid stream %q: calls=%d frames=%+v", body, calls, frames)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
app.streamCreditCallPreauth(httptest.NewRecorder(), preauthStreamRequest("", "AccountVerification", "").WithContext(ctx))
|
|
if calls != 1 {
|
|
t.Error("already-cancelled request dispatched a transaction")
|
|
}
|
|
}
|
|
}
|