backend requirements onthe xustomer app

This commit is contained in:
2026-09-07 10:55:11 +05:30
parent 35675d8a9b
commit 1b2690b21a
56 changed files with 13342 additions and 1071 deletions

View File

@@ -78,18 +78,46 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
})
// --------------------
// CUSTOMER APIS
// CUSTOMER APIS — doormile_cx
// --------------------
// The customer books a PICKUP, not a shipment: one visit, 1..N
// destinations, and a tracking number per destination minted only when the
// miler completes the pickup. Everything under /customer/* answers in the
// customer envelope ({success, data, message}; errors carry error.code) —
// auth included, deliberately unlike /miler/verify-pin, whose payload sits
// outside `data` and cost the miler client a release to discover.
//
// This replaces the PIN-based customer surface (register / login /
// verify-pin / reset-pin, and the single-destination booking create, list,
// detail and cancel). Those were retired rather than moved: a booking with
// one delivery address in its own columns is not a shape the product has
// any more.
customer := api.Group("/customer")
customer.Post("/register", controllers.RegisterCustomer(cfg))
customer.Post("/login", authThrottle, controllers.LoginCustomer)
customer.Post("/verify-pin", authThrottle, controllers.VerifyCustomerPin(cfg))
customer.Post("/reset-pin", authThrottle, controllers.ResetCustomerPin)
customer.Post("/send-email-otp", authThrottle, controllers.SendCustomerEmailOtp(cfg))
customer.Post("/verify-email-otp", authThrottle, controllers.VerifyCustomerEmailOtp())
// Auth — a 4-digit code to a phone or an email address, no password
// anywhere. Throttled on the shared budget with every other credential
// endpoint so an attacker cannot reset it by rotating between them.
customer.Post("/auth/otp/request", authThrottle, controllers.CxRequestOtp(cfg))
customer.Post("/auth/signup", authThrottle, controllers.CxSignup(cfg))
// Idempotent: the client retries on flaky networks, and a replayed verify
// must return the original session rather than mint a second one.
customer.Post("/auth/otp/verify", authThrottle, middlewares.Idempotency(), controllers.CxVerifyOtp(cfg))
customer.Post("/auth/refresh", authThrottle, controllers.CxRefresh(cfg))
// Serviceability and configuration are read before sign-in: the booking
// form is explorable without an account, and gating the state picker behind
// auth would make the app's first screen a login wall.
customer.Get("/serviceability/states", controllers.GetCxStates)
customer.Get("/serviceability/states/:stateCode/districts", controllers.GetCxDistricts)
customer.Get("/pickup-slots", controllers.GetCxPickupSlots)
customer.Get("/config/booking-limits", controllers.GetCxBookingLimits)
// Authenticated Customer App routes
customerAuth := customer.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(9))
customerAuth.Get("/auth/me", controllers.CxMe)
customerAuth.Post("/auth/logout", controllers.CxLogout)
customerAuth.Get("/profile", controllers.GetCustomerProfile)
customerAuth.Put("/profile", controllers.UpdateCustomerProfile)
@@ -98,14 +126,38 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
customerAuth.Put("/locations/:id", controllers.UpdateCustomerLocation)
customerAuth.Delete("/locations/:id", controllers.DeleteCustomerLocation)
customerAuth.Put("/device-token", controllers.SaveCustomerDeviceToken)
// Push registration. One row per device token, not one column per customer:
// a phone and a tablet both have to receive the delivery notification.
customerAuth.Post("/devices", controllers.RegisterCxDevice)
customerAuth.Delete("/devices/:token", controllers.UnregisterCxDevice)
customerAuth.Post("/bookings", middlewares.CityGateMiddleware, controllers.CreateCustomerBooking)
customerAuth.Get("/bookings", controllers.GetCustomerBookings)
customerAuth.Get("/bookings/:bookingid", controllers.GetCustomerBookingDetails)
customerAuth.Post("/bookings/:bookingid/cancel", controllers.CancelCustomerBooking)
customerAuth.Get("/bookings/:bookingid/price", controllers.GetCustomerBookingQuote)
customerAuth.Get("/track/:trackingno", controllers.TrackConsignment)
// Places are proxied, never keyed: the legacy rider app shipped a Maps key
// in the binary and it had to be revoked. The customer app is handed
// results, not credentials.
customerAuth.Get("/places/reverse-geocode", controllers.ReverseGeocodeCx(cfg))
customerAuth.Get("/places/search", controllers.SearchCxPlaces(cfg))
// Called on every route and package-count change, so it is cheap and
// cacheable — and a failed estimate never blocks a booking.
customerAuth.Post("/fare/estimate", controllers.EstimateCxFare)
// Idempotency-Key on create: the client retries over bad networks and a
// duplicate pickup is unacceptable.
customerAuth.Post("/bookings", middlewares.CityGateMiddleware, middlewares.Idempotency(), controllers.CreateCxBooking)
customerAuth.Get("/bookings", controllers.GetCxBookings)
customerAuth.Get("/bookings/:reference", controllers.GetCxBookingDetail)
customerAuth.Post("/bookings/:reference/cancel", controllers.CancelCxBooking)
customerAuth.Patch("/bookings/:reference/destinations/:index", controllers.PatchCxDestination)
// One order by tracking number, for push deep links (doormile://track/…).
customerAuth.Get("/orders/:trackingId", controllers.GetCxOrder)
// QA only. Refused outright unless ENV is non-production AND
// CX_ALLOW_STAGE_OVERRIDE=true — two independent switches, because either
// one being wrong in production would let any customer mark their own
// parcel delivered. It exists so every tracking state is reachable for
// design QA and the app's debug stepper can be deleted.
customerAuth.Post("/ops/bookings/:reference/stage", controllers.ForceCxStage)
// --------------------
// MILER APIS

View File

@@ -0,0 +1,405 @@
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)
}
}