backend requirements onthe xustomer app
This commit is contained in:
@@ -39,3 +39,21 @@ func CityGateMiddleware(c *fiber.Ctx) error {
|
||||
"code": "CITY_NOT_SUPPORTED",
|
||||
})
|
||||
}
|
||||
|
||||
// PincodeInOperatingCity reports whether a pincode falls in a city Doormile
|
||||
// runs in, and names it.
|
||||
//
|
||||
// Exported because the customer app's booking request does not carry a
|
||||
// pickuppincode at all — its pickup point is a title/sub/lat/lng from the place
|
||||
// search, so CityGateMiddleware's body sniff finds nothing and waves it
|
||||
// through. A middleware that silently no-ops on the one caller it matters most
|
||||
// for is worse than no middleware, so the customer handler resolves the pickup
|
||||
// point to a pincode itself and asks this directly.
|
||||
func PincodeInOperatingCity(pincode string) (string, bool) {
|
||||
pincode = strings.TrimSpace(pincode)
|
||||
if len(pincode) < 3 {
|
||||
return "", false
|
||||
}
|
||||
city, ok := operatingCityPrefixes[pincode[:3]]
|
||||
return city, ok
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package middlewares
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -37,8 +39,7 @@ func Idempotency() fiber.Handler {
|
||||
if key == "" || db.Rdb == nil {
|
||||
return c.Next()
|
||||
}
|
||||
uid, _ := c.Locals("userid").(int)
|
||||
base := fmt.Sprintf("idem:%d:%s", uid, key)
|
||||
base := "idem:" + idempotencyScope(c) + ":" + key
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
@@ -80,3 +81,32 @@ func Idempotency() fiber.Handler {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// idempotencyScope namespaces a key so one caller's stored response can never
|
||||
// be replayed to another.
|
||||
//
|
||||
// For an authenticated request the caller's own user id is the scope, which is
|
||||
// what this middleware has always used.
|
||||
//
|
||||
// An UNAUTHENTICATED request has no user id, and defaulting to 0 would put
|
||||
// every anonymous caller in one namespace: two customers who happened to pick
|
||||
// the same Idempotency-Key on POST /customer/auth/otp/verify would collide, and
|
||||
// the second would be handed the first's access token, refresh token and
|
||||
// customer record. So an anonymous request is scoped by the request path and a
|
||||
// hash of its body instead — the same body replays, a different body does not,
|
||||
// and one person's session can never be served to another.
|
||||
// The authenticated form is byte-identical to what this middleware has always
|
||||
// produced ("idem:<uid>:<key>"). Changing it would orphan every in-flight key
|
||||
// in Redis at deploy time, and a rider retrying a pickup-complete across that
|
||||
// boundary would execute it a second time instead of replaying — the exact
|
||||
// double-collection this middleware exists to prevent.
|
||||
func idempotencyScope(c *fiber.Ctx) string {
|
||||
if uid, ok := c.Locals("userid").(int); ok && uid != 0 {
|
||||
return fmt.Sprintf("%d", uid)
|
||||
}
|
||||
// Anonymous callers get their own namespace shape, which no previous key
|
||||
// can collide with: every key written before this change had a numeric
|
||||
// scope, and this one never is.
|
||||
sum := sha256.Sum256(append([]byte(c.Path()+"\x00"), c.Body()...))
|
||||
return "anon-" + hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
122
middlewares/idempotency_test.go
Normal file
122
middlewares/idempotency_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// The idempotency key namespace is a security boundary, not bookkeeping.
|
||||
//
|
||||
// POST /customer/auth/otp/verify is UNAUTHENTICATED, so c.Locals("userid") is
|
||||
// absent there. Scoping on it anyway put every anonymous caller in one
|
||||
// namespace: two customers picking the same Idempotency-Key would collide and
|
||||
// the second would be handed the first's access token, refresh token and
|
||||
// customer record. These tests pin the fix.
|
||||
|
||||
// scopeFor runs idempotencyScope inside a real request and returns what it
|
||||
// produced.
|
||||
func scopeFor(t *testing.T, authedUserID int, path, body string) string {
|
||||
t.Helper()
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Post("/*", func(c *fiber.Ctx) error {
|
||||
if authedUserID != 0 {
|
||||
c.Locals("userid", authedUserID)
|
||||
}
|
||||
return c.SendString(idempotencyScope(c))
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("POST", path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req, 5000)
|
||||
if err != nil {
|
||||
t.Fatalf("test request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
buf := make([]byte, 256)
|
||||
n, _ := resp.Body.Read(buf)
|
||||
return string(buf[:n])
|
||||
}
|
||||
|
||||
// Two anonymous callers sending DIFFERENT bodies must never share a namespace,
|
||||
// even with an identical Idempotency-Key. This is the session-handover bug.
|
||||
func TestAnonymousCallersNeverShareAnIdempotencyNamespace(t *testing.T) {
|
||||
alice := scopeFor(t, 0, "/customer/auth/otp/verify",
|
||||
`{"identifier":"+919876543210","code":"1111"}`)
|
||||
bob := scopeFor(t, 0, "/customer/auth/otp/verify",
|
||||
`{"identifier":"+919000000001","code":"2222"}`)
|
||||
|
||||
if alice == bob {
|
||||
t.Fatalf("two different anonymous requests share the scope %q — "+
|
||||
"one customer's session could be replayed to another", alice)
|
||||
}
|
||||
if alice == "" || bob == "" {
|
||||
t.Fatal("empty scope: every request must land in some namespace")
|
||||
}
|
||||
}
|
||||
|
||||
// The same anonymous caller repeating the SAME request must replay — that is
|
||||
// the whole point of idempotency.
|
||||
func TestIdenticalAnonymousRequestReplays(t *testing.T) {
|
||||
body := `{"identifier":"+919876543210","code":"4821"}`
|
||||
first := scopeFor(t, 0, "/customer/auth/otp/verify", body)
|
||||
again := scopeFor(t, 0, "/customer/auth/otp/verify", body)
|
||||
|
||||
if first != again {
|
||||
t.Errorf("the same request produced two scopes (%q, %q) — a retry would "+
|
||||
"re-execute instead of replaying", first, again)
|
||||
}
|
||||
}
|
||||
|
||||
// The authenticated format is byte-identical to what this middleware has always
|
||||
// produced. Changing it would orphan every in-flight key in Redis at deploy
|
||||
// time, and a rider retrying a pickup-complete across that boundary would
|
||||
// collect COD twice instead of replaying.
|
||||
func TestAuthenticatedScopeFormatIsUnchanged(t *testing.T) {
|
||||
got := scopeFor(t, 42, "/miler/bookings/7/pickup-complete", `{}`)
|
||||
if got != "42" {
|
||||
t.Errorf("authenticated scope = %q, want %q — the stored key format must "+
|
||||
"not change across a deploy", got, "42")
|
||||
}
|
||||
}
|
||||
|
||||
// An anonymous scope can never collide with an authenticated one: the old
|
||||
// format is always numeric, the new one never is.
|
||||
func TestAnonymousScopeCannotCollideWithAnAuthenticatedOne(t *testing.T) {
|
||||
anon := scopeFor(t, 0, "/customer/auth/otp/verify", `{"code":"1"}`)
|
||||
if !strings.HasPrefix(anon, "anon-") {
|
||||
t.Errorf("anonymous scope = %q, want an 'anon-' prefix so it cannot look "+
|
||||
"like a user id", anon)
|
||||
}
|
||||
}
|
||||
|
||||
// The operating-city gate, exported because the customer booking shape carries
|
||||
// no pincode for CityGateMiddleware to sniff.
|
||||
func TestPincodeInOperatingCity(t *testing.T) {
|
||||
cases := []struct {
|
||||
pincode string
|
||||
wantCity string
|
||||
wantOK bool
|
||||
}{
|
||||
{"641012", "Coimbatore", true},
|
||||
{"600001", "Chennai", true},
|
||||
{"560034", "Bengaluru", true},
|
||||
{"500081", "Hyderabad", true},
|
||||
{"629001", "Nagercoil", true},
|
||||
{"110001", "", false}, // Delhi — not an operating city
|
||||
{"12", "", false}, // too short to classify
|
||||
{"", "", false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
city, ok := PincodeInOperatingCity(tc.pincode)
|
||||
if ok != tc.wantOK || city != tc.wantCity {
|
||||
t.Errorf("PincodeInOperatingCity(%q) = (%q, %v), want (%q, %v)",
|
||||
tc.pincode, city, ok, tc.wantCity, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,21 +19,33 @@ func ZapLogger() fiber.Handler {
|
||||
method := c.Method()
|
||||
path := c.Path()
|
||||
|
||||
// Client identity and the request id travel with every log line.
|
||||
//
|
||||
// X-Client / X-Platform are what the customer app sends
|
||||
// ("doormile-cx/1.0.0+12", "android"), and they are the only way to
|
||||
// answer "is this failing on one build or everywhere" — which is the
|
||||
// first question asked when an app-side report arrives. The request id
|
||||
// is what correlates a customer saying "it failed" with the line that
|
||||
// recorded the real error behind the customer-safe message.
|
||||
//
|
||||
// Recorded as empty rather than omitted when absent, so a caller that
|
||||
// sends nothing is visibly a caller that sends nothing.
|
||||
fields := []interface{}{
|
||||
"method", method,
|
||||
"path", path,
|
||||
"status", status,
|
||||
"latency", latency,
|
||||
"client", c.Get("X-Client"),
|
||||
"platform", c.Get("X-Platform"),
|
||||
}
|
||||
if id, ok := c.Locals("requestid").(string); ok && id != "" {
|
||||
fields = append(fields, "requestid", id)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
utils.Error("API request error",
|
||||
"method", method,
|
||||
"path", path,
|
||||
"status", status,
|
||||
"latency", latency,
|
||||
"error", err.Error(),
|
||||
)
|
||||
utils.Error("API request error", append(fields, "error", err.Error())...)
|
||||
} else {
|
||||
utils.Info("API request success",
|
||||
"method", method,
|
||||
"path", path,
|
||||
"status", status,
|
||||
"latency", latency,
|
||||
)
|
||||
utils.Info("API request success", fields...)
|
||||
}
|
||||
|
||||
return err
|
||||
|
||||
33
middlewares/requestid.go
Normal file
33
middlewares/requestid.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// RequestID echoes the caller's X-Request-Id on every response, errors
|
||||
// included, and mints one when the caller did not send it. The customer app
|
||||
// funnels all failures into a single retryable error state, so a support
|
||||
// conversation about "it failed" has nothing to correlate on unless the id the
|
||||
// client already holds comes back on the failing response too.
|
||||
//
|
||||
// Also stashed in c.Locals("requestid") so handlers can log it alongside the
|
||||
// real error they are hiding behind a customer-safe message.
|
||||
func RequestID() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
id := c.Get("X-Request-Id")
|
||||
if id == "" {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err == nil {
|
||||
id = hex.EncodeToString(b)
|
||||
}
|
||||
}
|
||||
if id != "" {
|
||||
c.Locals("requestid", id)
|
||||
c.Set("X-Request-Id", id)
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user