From 45d183ec4dcc3d6fc005bbd0f5218836915311a5 Mon Sep 17 00:00:00 2001 From: Phloraxx Date: Sun, 26 Jul 2026 12:55:40 +0000 Subject: [PATCH] Fix JSON decoding with PocketBase rereadable bodies --- internal/api/api.go | 12 +++++++++- internal/api/api_test.go | 51 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/internal/api/api.go b/internal/api/api.go index 0d0857a..5f57988 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "crypto/subtle" "encoding/json" "errors" @@ -358,7 +359,16 @@ func constantTimeEqual(expected, actual string) bool { } func decodeJSON(e *core.RequestEvent, dst any) error { - decoder := json.NewDecoder(e.Request.Body) + // PocketBase wraps request bodies in a rereadable reader so middleware can + // inspect them more than once. Decode from an immutable snapshot here; + // otherwise a second Decode used to prove EOF can observe the rewound body + // as a second JSON value on real network requests. + body, err := io.ReadAll(e.Request.Body) + if err != nil { + return err + } + + decoder := json.NewDecoder(bytes.NewReader(body)) decoder.DisallowUnknownFields() if err := decoder.Decode(dst); err != nil { return err diff --git a/internal/api/api_test.go b/internal/api/api_test.go index b4d46e7..4595f17 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1,7 +1,9 @@ package api import ( + "io" "net/http" + "net/http/httptest" "strings" "testing" "time" @@ -10,6 +12,7 @@ import ( "github.com/Phloraxx/payment-api/internal/payments" "github.com/Phloraxx/payment-api/internal/sms" _ "github.com/Phloraxx/payment-api/migrations" + "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tests" ) @@ -88,6 +91,54 @@ func TestPaymentAPIAuthenticationAndAmountValidation(t *testing.T) { } } +func TestPaymentCreateThroughRealHTTPServer(t *testing.T) { + app := apiTestFactory(t, nil) + defer app.Cleanup() + + router, err := apis.NewRouter(app) + if err != nil { + t.Fatal(err) + } + serveEvent := &core.ServeEvent{App: app, Router: router} + if err := app.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error { return nil }); err != nil { + t.Fatal(err) + } + mux, err := serveEvent.Router.BuildMux() + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(mux) + defer server.Close() + + req, err := http.NewRequest(http.MethodPost, server.URL+"/api/payments", strings.NewReader(`{"amount":100,"externalId":"network-http"}`)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer api-secret") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Idempotency-Key", "network-http-idem") + + res, err := server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusCreated { + t.Fatalf("status = %d, body = %s", res.StatusCode, body) + } + content := string(body) + for _, want := range []string{`"requestedAmount":100`, `"externalId":"network-http"`, `upi://pay?`} { + if !strings.Contains(content, want) { + t.Fatalf("response missing %q: %s", want, content) + } + } +} + func TestPublicPaymentStatusRedactsSensitiveEvidence(t *testing.T) { const paymentID = "paytest00000001" scenario := tests.ApiScenario{