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(`1234Sale`)) 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, "") case "malformed": io.WriteString(w, "") 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, "") 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", "\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()) } }