package routes_test import ( "net/http" "strings" "testing" "github.com/gofiber/fiber/v2" ) // Routing contract for the customer surface, plus a regression guard proving // the miler, admin and hub surfaces were not touched. // // Same boundary as routes_logistics_test.go: everything up to the handler is // tested here with no database. A wrong path, a route registered under the // wrong group, a missing auth gate, or a param route shadowing a static one are // all real bugs that compile perfectly and that unit tests cannot see. A 200 is // never claimed — that needs Postgres and Redis. // cxAuthedRoutes is every authenticated route in the customer contract. If a // path here stops resolving, the app gets a router 404 with no envelope at all // and the client's error state cannot explain it. var cxAuthedRoutes = []struct { method string path string body string }{ {http.MethodGet, "/api/v1/customer/auth/me", ""}, {http.MethodPost, "/api/v1/customer/auth/logout", `{}`}, {http.MethodGet, "/api/v1/customer/profile", ""}, {http.MethodPut, "/api/v1/customer/profile", `{"name":"Joe Oommen"}`}, {http.MethodGet, "/api/v1/customer/locations", ""}, {http.MethodPost, "/api/v1/customer/locations", `{"address":"a","pincode":"641012","latitude":11.0,"longitude":76.9}`}, {http.MethodPut, "/api/v1/customer/locations/1", `{"address":"a"}`}, {http.MethodDelete, "/api/v1/customer/locations/1", ""}, {http.MethodPost, "/api/v1/customer/devices", `{"token":"abc","platform":"android"}`}, {http.MethodDelete, "/api/v1/customer/devices/abc", ""}, {http.MethodGet, "/api/v1/customer/places/reverse-geocode?lat=11.0&lng=76.9", ""}, {http.MethodGet, "/api/v1/customer/places/search?q=brookefields", ""}, {http.MethodPost, "/api/v1/customer/fare/estimate", `{"destinations":[{"stateCode":"TN","districtCode":"TN-MAA","packageCount":1}]}`}, {http.MethodPost, "/api/v1/customer/bookings", `{"slotId":"slot_20991231_t1","destinations":[]}`}, {http.MethodGet, "/api/v1/customer/bookings", ""}, {http.MethodGet, "/api/v1/customer/bookings/DM-482913", ""}, {http.MethodPost, "/api/v1/customer/bookings/DM-482913/cancel", `{"reason":"Package not ready"}`}, {http.MethodPatch, "/api/v1/customer/bookings/DM-482913/destinations/0", `{"street":"12th Main"}`}, {http.MethodGet, "/api/v1/customer/orders/DMX10482913", ""}, {http.MethodPost, "/api/v1/customer/ops/bookings/DM-482913/stage", `{"stage":"delivered"}`}, } // cxPublicRoutes are reachable without a token on purpose: the booking form is // explorable before sign-in, and gating the state picker behind auth would make // the app's first screen a login wall. var cxPublicRoutes = []struct { method string path string body string }{ {http.MethodPost, "/api/v1/customer/auth/otp/request", `{"identifier":""}`}, {http.MethodPost, "/api/v1/customer/auth/signup", `{"name":"J"}`}, {http.MethodPost, "/api/v1/customer/auth/otp/verify", `{"identifier":"nope","code":""}`}, {http.MethodPost, "/api/v1/customer/auth/refresh", `{"refreshToken":""}`}, } // Every authenticated customer route exists and is gated. A missing token must // produce 401, never 404 (route absent) and never 500 (gate skipped and the // handler reached a nil database). func TestCustomerRoutesRequireATokenAndExist(t *testing.T) { app := newApp() for _, r := range cxAuthedRoutes { t.Run(r.method+" "+r.path, func(t *testing.T) { status, body := do(t, app, r.method, r.path, "", r.body) if status == fiber.StatusNotFound { t.Fatalf("route is not registered — the client would get a router 404 with no envelope (body: %s)", body) } if status >= 500 { t.Fatalf("status = %d: the auth gate did not stop this before the handler (body: %s)", status, body) } if status != fiber.StatusUnauthorized { t.Errorf("status = %d without a token, want 401 (body: %s)", status, body) } }) } } // A customer route must refuse a token from any other surface. Role 9 is the // customer; 5 is a miler, 1 an admin, 6 hub staff. A miler token opening a // customer's booking would be a cross-surface data leak. func TestCustomerRoutesRefuseOtherRoles(t *testing.T) { app := newApp() for _, roleID := range []int{1, 3, 4, 5, 6} { bearer := token(t, 99, roleID) for _, r := range cxAuthedRoutes { status, body := do(t, app, r.method, r.path, bearer, r.body) if status != fiber.StatusForbidden { t.Errorf("role %d on %s %s: status = %d, want 403 (body: %s)", roleID, r.method, r.path, status, body) } } } } // The pre-auth routes are reachable without a token. They still must not 404 // (route missing) and must not 500 — a malformed identifier is the caller's // mistake and has to be answered as one. func TestCustomerPublicRoutesAreReachableWithoutAToken(t *testing.T) { app := newApp() for _, r := range cxPublicRoutes { t.Run(r.method+" "+r.path, func(t *testing.T) { status, body := do(t, app, r.method, r.path, "", r.body) if status == fiber.StatusNotFound { t.Fatalf("route is not registered (body: %s)", body) } if status >= 500 { t.Fatalf("status = %d on a malformed request, want 4xx (body: %s)", status, body) } if status < 400 { t.Errorf("status = %d on a deliberately invalid body, want 4xx (body: %s)", status, body) } // The refusal has to come from the HANDLER, in the customer // envelope, not from the auth middleware. These four routes are // reached before sign-in, so a bare "authorization header is // required" here would mean one had been registered after the // group's Use and silently put behind a login wall. if !strings.Contains(body, `"error"`) { t.Errorf("refusal did not come from the handler — this route may have been "+ "registered behind the auth middleware (body: %s)", body) } }) } } // The retired PIN surface must be gone. Leaving it registered would keep a // credential endpoint alive that could reset any account from a phone number, // and would let the old app keep writing single-destination bookings that the // new tracking screen cannot render. func TestRetiredCustomerRoutesAreRemoved(t *testing.T) { app := newApp() retired := []struct { method string path string }{ {http.MethodPost, "/api/v1/customer/register"}, {http.MethodPost, "/api/v1/customer/login"}, {http.MethodPost, "/api/v1/customer/verify-pin"}, {http.MethodPost, "/api/v1/customer/reset-pin"}, {http.MethodPost, "/api/v1/customer/send-email-otp"}, {http.MethodPost, "/api/v1/customer/verify-email-otp"}, {http.MethodGet, "/api/v1/customer/track/DM-TRK-ABCD-123"}, {http.MethodGet, "/api/v1/customer/bookings/42/price"}, {http.MethodPut, "/api/v1/customer/device-token"}, } for _, r := range retired { status, body := do(t, app, r.method, r.path, "", `{}`) // The assertion is "no handler serves this any more", not "404". // // Fiber mounts customerAuth via customer.Use(...) on the /customer // PREFIX, so any path under it that matches no route falls through to // the auth middleware and answers 401 rather than 404. That is // pre-existing behaviour of every group in this router (/miler, // /admin and /hub all do it) and is not something this work changed — // but it is what the retired PIN endpoints now return, and the app // developer needs to know that a stale build calling // POST /customer/login sees 401, not 404. if status < 400 { t.Errorf("%s %s: status = %d — a retired route is still being served (body: %s)", r.method, r.path, status, body) } if status >= 500 { t.Errorf("%s %s: status = %d — a retired route should refuse cleanly, not fault (body: %s)", r.method, r.path, status, body) } // The old PIN handlers answered 200/201 with a token. Nothing here may // still mint one. if strings.Contains(body, `"token"`) || strings.Contains(body, `"accessToken"`) { t.Errorf("%s %s still returns a credential: %s", r.method, r.path, body) } } } // ── Regression guard: the other surfaces are untouched ─────────────────────── // milerSurface is every miler route, with the role it requires. This exists to // answer one question in CI rather than by inspection: did the customer work // change the rider's API? A path disappearing, moving group, or losing its role // gate fails here. var milerSurface = []struct { method string path string }{ {http.MethodGet, "/api/v1/miler/profile"}, {http.MethodPut, "/api/v1/miler/profile"}, {http.MethodPut, "/api/v1/miler/device-token"}, {http.MethodPut, "/api/v1/miler/location"}, {http.MethodPut, "/api/v1/miler/availability"}, {http.MethodGet, "/api/v1/miler/assignments"}, {http.MethodGet, "/api/v1/miler/assignments/1"}, {http.MethodPost, "/api/v1/miler/assignments/1/accept"}, {http.MethodPost, "/api/v1/miler/assignments/1/reject"}, {http.MethodPost, "/api/v1/miler/bookings/1/reached"}, {http.MethodPatch, "/api/v1/miler/bookings/1/addresses"}, {http.MethodPost, "/api/v1/miler/bookings/1/parcel"}, {http.MethodPost, "/api/v1/miler/bookings/1/payment"}, {http.MethodPost, "/api/v1/miler/bookings/1/pickup-complete"}, {http.MethodPost, "/api/v1/miler/bookings/1/vehicle-required"}, {http.MethodPost, "/api/v1/miler/bookings/1/cancel"}, {http.MethodPost, "/api/v1/miler/bookings/1/skip"}, {http.MethodGet, "/api/v1/miler/bookings"}, {http.MethodPost, "/api/v1/miler/duty/start"}, {http.MethodPut, "/api/v1/miler/duty/end"}, {http.MethodGet, "/api/v1/miler/duty/current"}, {http.MethodGet, "/api/v1/miler/consignments/1"}, {http.MethodPost, "/api/v1/miler/consignments/1/start-delivery"}, {http.MethodPost, "/api/v1/miler/consignments/1/deliver"}, {http.MethodPost, "/api/v1/miler/consignments/1/skip"}, {http.MethodPost, "/api/v1/miler/consignments/1/inward-at-hub"}, {http.MethodGet, "/api/v1/miler/bases"}, {http.MethodGet, "/api/v1/miler/earnings"}, {http.MethodGet, "/api/v1/miler/notifications"}, {http.MethodPost, "/api/v1/miler/support"}, {http.MethodGet, "/api/v1/miler/support"}, {http.MethodPost, "/api/v1/miler/uploads/sign"}, } // consoleSurface is a representative slice of the admin and hub consoles — // including every route the customer work touched code behind (assignment, // express booking creation, hub queues). var consoleSurface = []struct { method string path string role int }{ {http.MethodGet, "/api/v1/admin/dashboard", 1}, {http.MethodGet, "/api/v1/admin/bookings", 1}, {http.MethodGet, "/api/v1/admin/bookings/1", 1}, {http.MethodPost, "/api/v1/admin/bookings/1/assign-miler", 1}, {http.MethodPost, "/api/v1/admin/bookings/1/cancel", 1}, {http.MethodPost, "/api/v1/admin/expressbooking", 1}, {http.MethodPost, "/api/v1/admin/expressbooking/bulk", 1}, {http.MethodGet, "/api/v1/admin/consignments", 1}, {http.MethodGet, "/api/v1/admin/customers", 1}, {http.MethodGet, "/api/v1/hub/dashboard", 6}, {http.MethodGet, "/api/v1/hub/bookings/unassigned", 6}, {http.MethodPost, "/api/v1/hub/bookings/1/assign-miler", 6}, {http.MethodPost, "/api/v1/hub/bookings/1/auto-assign", 6}, {http.MethodPost, "/api/v1/hub/bookings/batch-assign", 6}, {http.MethodGet, "/api/v1/hub/inbound/expected", 6}, } // Every miler route still exists and is still gated to role 5. This is the // regression guard for "did the customer work break the rider app's routing". func TestMilerSurfaceIsUnchanged(t *testing.T) { app := newApp() customerBearer := token(t, 77, 9) for _, r := range milerSurface { t.Run(r.method+" "+r.path, func(t *testing.T) { if status, body := do(t, app, r.method, r.path, "", `{}`); status != fiber.StatusUnauthorized { t.Errorf("no token: status = %d, want 401 (body: %s)", status, body) } // And a customer token must not reach a rider endpoint. if status, body := do(t, app, r.method, r.path, customerBearer, `{}`); status != fiber.StatusForbidden { t.Errorf("customer token: status = %d, want 403 (body: %s)", status, body) } }) } } func TestConsoleSurfaceIsUnchanged(t *testing.T) { app := newApp() customerBearer := token(t, 77, 9) for _, r := range consoleSurface { t.Run(r.method+" "+r.path, func(t *testing.T) { if status, body := do(t, app, r.method, r.path, "", `{}`); status != fiber.StatusUnauthorized { t.Errorf("no token: status = %d, want 401 (body: %s)", status, body) } if status, body := do(t, app, r.method, r.path, customerBearer, `{}`); status != fiber.StatusForbidden { t.Errorf("customer token: status = %d, want 403 (body: %s)", status, body) } }) } } // The customer envelope must not have leaked onto the other surfaces. A miler // or console client reads `code` at the top level, not `error.code`, and a // silently reshaped error body is the kind of thing that costs a release to // discover. func TestOtherSurfacesKeepTheirOwnErrorEnvelope(t *testing.T) { app := newApp() for _, path := range []string{ "/api/v1/miler/bookings", "/api/v1/admin/bookings", "/api/v1/hub/dashboard", } { _, body := do(t, app, http.MethodGet, path, "", "") if strings.Contains(body, `"error"`) { t.Errorf("%s: refusal body carries the customer surface's nested error object: %s", path, body) } if !strings.Contains(body, `"success"`) || !strings.Contains(body, `"message"`) { t.Errorf("%s: refusal body lost its original shape: %s", path, body) } } } // The four catalogue reads must be reachable WITHOUT a token. // // The booking form is explorable before sign-in — the state picker is the app's // first screen. These four are registered before `customer.Use(...)`, which is // the only thing keeping them public: move any of them below that line and the // app's opening screen silently becomes a login wall, with nothing else to // catch it. `cxPublicRoutes` above covers the auth endpoints; this covers the // catalogue, which had no such assertion until a wrong URL in the field // surfaced the gap. // // With no database configured these reach the handler and fault on a nil // db.DB — which is the proof. A 401 or 403 would mean the request never got // that far. func TestCatalogueRoutesAreNotBehindAuth(t *testing.T) { app := newApp() for _, path := range []string{ "/api/v1/customer/serviceability/states", "/api/v1/customer/serviceability/states/TN/districts", "/api/v1/customer/pickup-slots?lat=11.0168&lng=76.9558", "/api/v1/customer/config/booking-limits", } { t.Run(path, func(t *testing.T) { status, body := do(t, app, http.MethodGet, path, "", "") if status == fiber.StatusUnauthorized { t.Fatalf("status = 401: this route is behind the auth middleware. "+ "The booking form must be explorable before sign-in — check it is "+ "registered BEFORE customer.Use(...) in routes.go (body: %s)", body) } if status == fiber.StatusForbidden { t.Fatalf("status = 403: this route is behind the role gate (body: %s)", body) } if status == fiber.StatusNotFound { t.Fatalf("status = 404: route not registered (body: %s)", body) } }) } // A wrong-role token must not change that answer either — these routes do // not consult the caller at all. adminBearer := token(t, 1, 1) for _, path := range []string{ "/api/v1/customer/serviceability/states", "/api/v1/customer/config/booking-limits", } { if status, body := do(t, app, http.MethodGet, path, adminBearer, ""); status == fiber.StatusForbidden { t.Errorf("%s refused an admin token with 403 — a catalogue read is public "+ "and should ignore the caller entirely (body: %s)", path, body) } } } // A MISTYPED path under /customer/* answers 401 or 403, never 404. // // Fiber mounts customerAuth via customer.Use(...) on the /customer PREFIX, so // any path matching no route falls through to the auth middleware. Every group // in this router behaves this way (/miler, /admin, /hub included) and it is not // something the customer work introduced — but it is genuinely confusing in the // field: a typo like /serviceability/state (singular) reports an auth problem // rather than a missing route, which sends people looking for a permissions bug // that is not there. // // Asserted so the behaviour is at least documented and deliberate. func TestMistypedCustomerPathReportsAuthNotNotFound(t *testing.T) { app := newApp() const typo = "/api/v1/customer/serviceability/state" // note: singular status, body := do(t, app, http.MethodGet, typo, "", "") if status != fiber.StatusUnauthorized { t.Errorf("no token on a mistyped path: status = %d, want 401 (body: %s)", status, body) } status, body = do(t, app, http.MethodGet, typo, token(t, 1, 1), "") if status != fiber.StatusForbidden { t.Errorf("wrong-role token on a mistyped path: status = %d, want 403 (body: %s)", status, body) } if !strings.Contains(body, "insufficient permissions") { t.Errorf("unexpected refusal body: %s", body) } }