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") } }