Files
doormile_backend/middlewares/idempotency_test.go
2026-09-16 11:42:06 +05:30

173 lines
6.2 KiB
Go

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)
}
}
}
// What may be replayed from the idempotency cache.
//
// The middleware exists to stop a retry repeating a SIDE EFFECT — a second
// pickup, a second COD collection, a second session. It used to cache every
// status below 500, which quietly extended that to refusals.
//
// POST /customer/auth/otp/verify is where it bit: a wrong code returns 401, and
// the whole purpose of the screen is that the customer then gets it right. With
// the 401 cached for 24 hours, the retry that should have worked replayed the
// old refusal. Confirmed live against api.doormile.com — the second attempt
// came back carrying `Idempotent-Replay: true`.
func TestOnlySuccessfulResponsesAreCacheable(t *testing.T) {
cases := []struct {
status int
want bool
why string
}{
{200, true, "a completed mutation is exactly what must not run twice"},
{201, true, "a created booking must not be created again"},
{204, true, "a completed no-content mutation still ran"},
{400, false, "a malformed body performed no side effect; re-running is free"},
{401, false, "the code was wrong; the retry is meant to be right"},
{403, false, "a permission can be granted between attempts"},
{404, false, "the record can exist by the time of the retry"},
{409, false, "a conflict can clear"},
{422, false, "a district can reopen"},
{429, false, "the rate-limit window rolls over"},
{500, false, "transient; the retry deserves a genuine second attempt"},
{503, false, "the dependency can come back"},
}
for _, tc := range cases {
if got := isCacheableStatus(tc.status); got != tc.want {
t.Errorf("status %d cacheable = %v, want %v — %s", tc.status, got, tc.want, tc.why)
}
}
}
// The specific regression, stated as itself: a failed sign-in must never be
// replayed to a customer who has since typed the right code.
func TestAFailedOtpVerifyIsNotCached(t *testing.T) {
if isCacheableStatus(fiber.StatusUnauthorized) {
t.Fatal("a 401 from /customer/auth/otp/verify would be cached for 24 hours, " +
"so the retry with the correct code replays the refusal instead of running")
}
}