406 lines
16 KiB
Go
406 lines
16 KiB
Go
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)
|
|
}
|
|
}
|