backend requirements onthe xustomer app
This commit is contained in:
@@ -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
|
||||
|
||||
405
routes/routes_customer_test.go
Normal file
405
routes/routes_customer_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user