updates on the api endpoints on the customer page and more
This commit is contained in:
@@ -193,6 +193,16 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
||||
milerAuth.Post("/consignments/:id/start-delivery", middlewares.Idempotency(), controllers.MilerStartDelivery)
|
||||
milerAuth.Post("/consignments/:id/deliver", middlewares.Idempotency(), controllers.MilerDeliverConsignment)
|
||||
milerAuth.Post("/consignments/:id/skip", controllers.MilerSkipDelivery)
|
||||
// Rider handover at a base. The authoritative record that a hub-routed parcel
|
||||
// physically changed hands; answers with the resulting state rather than a
|
||||
// bare 200, and carries the shared idempotency middleware because riders retry
|
||||
// on bad signal at a loading bay.
|
||||
milerAuth.Post("/consignments/:id/inward-at-hub", middlewares.Idempotency(), controllers.MilerInwardConsignmentAtHub)
|
||||
|
||||
// Base master data on a rider token — id, name, address, pincode and
|
||||
// coordinates for every active base. /admin/tenants/:id/locations is a
|
||||
// different dataset (a client's own sites) and is closed to role 5 by design.
|
||||
milerAuth.Get("/bases", controllers.MilerGetBases)
|
||||
|
||||
// Earnings
|
||||
milerAuth.Get("/earnings", controllers.MilerGetEarnings)
|
||||
@@ -366,6 +376,10 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
||||
hubAuth.Get("/inbound/today", controllers.GetHubInboundToday)
|
||||
hubAuth.Get("/inbound", controllers.GetHubInboundRange)
|
||||
hubAuth.Post("/bookings/:id/inbound", controllers.CreateInboundScan)
|
||||
// What riders are carrying towards this base but have not handed over yet,
|
||||
// and the matching receive/dispute action for when they arrive.
|
||||
hubAuth.Get("/inbound/expected", controllers.GetHubInboundExpected)
|
||||
hubAuth.Post("/inbound/:id/reconcile", controllers.ReconcileHubInbound)
|
||||
hubAuth.Post("/bookings/:id/assign-miler", controllers.HubAssignMiler)
|
||||
hubAuth.Post("/bookings/:id/auto-assign", controllers.HubAutoAssign)
|
||||
hubAuth.Post("/bookings/batch-assign", controllers.HubBatchAssign)
|
||||
|
||||
257
routes/routes_logistics_test.go
Normal file
257
routes/routes_logistics_test.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package routes_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/routes"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
)
|
||||
|
||||
// Real HTTP requests against the real router.
|
||||
//
|
||||
// WHAT THIS DOES AND DOES NOT PROVE.
|
||||
//
|
||||
// Every route in this file is behind AuthMiddleware + RoleCheckMiddleware, and
|
||||
// both reject before the handler runs — so a request with a wrong-role or absent
|
||||
// token never reaches a line that touches Postgres. That makes it possible to
|
||||
// exercise the entire HTTP surface with no database, and it is worth doing:
|
||||
// it catches a mistyped path, a route registered under the wrong group, a
|
||||
// missing middleware, and a param route shadowing a static one. Those are real
|
||||
// bugs that compile perfectly and that unit tests over pure functions cannot see.
|
||||
//
|
||||
// It does NOT prove a handler works. Nothing here reaches a query, a
|
||||
// transaction, or a response body built from real rows. A 200 from these
|
||||
// endpoints has never been observed and this file does not claim one.
|
||||
//
|
||||
// The line is drawn deliberately: everything up to the handler is tested here;
|
||||
// everything from the handler inwards needs a database and is still unverified.
|
||||
|
||||
const jwtSecret = "test-secret-for-routing-only"
|
||||
|
||||
func newApp() *fiber.App {
|
||||
// Recover is what main.go installs too. Here it also acts as a guardrail:
|
||||
// with no database configured, any request that DID reach a handler would
|
||||
// nil-panic and take the whole test binary down. Nothing in this file is
|
||||
// supposed to get that far — recover turns a mistake into a failed test
|
||||
// rather than a crashed run.
|
||||
app := fiber.New()
|
||||
app.Use(recover.New())
|
||||
routes.RegisterRoutes(app, &config.Config{JWTSecret: jwtSecret})
|
||||
return app
|
||||
}
|
||||
|
||||
// token mints a valid JWT for a role, so the role gate can be exercised
|
||||
// independently of whether a token parses at all.
|
||||
func token(t *testing.T, userID, roleID int) string {
|
||||
t.Helper()
|
||||
tok, err := utils.GenerateToken(userID, "test@doormile.com", roleID, 0, 1001, jwtSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("could not mint a %d-role token: %v", roleID, err)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
func do(t *testing.T, app *fiber.App, method, path, bearer, body string) (int, string) {
|
||||
t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != "" {
|
||||
rdr = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, rdr)
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := app.Test(req, int(10*time.Second/time.Millisecond))
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(out)
|
||||
}
|
||||
|
||||
// The routes added for the base-handover flow, with the role each one requires.
|
||||
var newRoutes = []struct {
|
||||
method string
|
||||
path string
|
||||
wantRole int
|
||||
body string
|
||||
}{
|
||||
{http.MethodPost, "/api/v1/miler/consignments/42/inward-at-hub", 5, `{"hub_id":7}`},
|
||||
{http.MethodGet, "/api/v1/miler/bases", 5, ""},
|
||||
{http.MethodGet, "/api/v1/hub/inbound/expected", 6, ""},
|
||||
{http.MethodPost, "/api/v1/hub/inbound/42/reconcile", 6, `{"received":true}`},
|
||||
}
|
||||
|
||||
// Routes that already existed and whose responses this work changed.
|
||||
var changedRoutes = []struct {
|
||||
method string
|
||||
path string
|
||||
wantRole int
|
||||
body string
|
||||
}{
|
||||
{http.MethodPost, "/api/v1/miler/bookings/42/pickup-complete", 5, ""},
|
||||
{http.MethodGet, "/api/v1/miler/bookings", 5, ""},
|
||||
{http.MethodGet, "/api/v1/miler/consignments/42", 5, ""},
|
||||
{http.MethodGet, "/api/v1/admin/bookings/42", 1, ""},
|
||||
{http.MethodPost, "/api/v1/admin/expressbooking", 1, `{"tenantid":1,"pickuppincode":"641004","parcels":[{"weight":1}]}`},
|
||||
{http.MethodGet, "/api/v1/hub/bookings/unassigned", 6, ""},
|
||||
{http.MethodGet, "/api/v1/hub/inbound/today", 6, ""},
|
||||
{http.MethodPost, "/api/v1/customer/bookings", 9, `{"pickuppincode":"641004"}`},
|
||||
}
|
||||
|
||||
func allRoutes() []struct {
|
||||
method string
|
||||
path string
|
||||
wantRole int
|
||||
body string
|
||||
} {
|
||||
return append(append([]struct {
|
||||
method string
|
||||
path string
|
||||
wantRole int
|
||||
body string
|
||||
}{}, newRoutes...), changedRoutes...)
|
||||
}
|
||||
|
||||
// A route that is registered rejects an anonymous request with 401. One that is
|
||||
// NOT registered falls through to Fiber's own 404 — which is exactly how a
|
||||
// mistyped path hides, since both "fail".
|
||||
func TestEveryRouteIsRegistered(t *testing.T) {
|
||||
app := newApp()
|
||||
for _, r := range allRoutes() {
|
||||
t.Run(r.method+" "+r.path, func(t *testing.T) {
|
||||
status, body := do(t, app, r.method, r.path, "", r.body)
|
||||
if status == http.StatusNotFound {
|
||||
t.Fatalf("route is NOT registered — got 404: %s", body)
|
||||
}
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("anonymous request should be 401, got %d: %s", status, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The gate that produced the "insufficient permissions" report: a valid token of
|
||||
// the wrong role must be refused, and refused BEFORE the handler runs — with no
|
||||
// database configured, a handler that executed would panic on a nil db.DB, so a
|
||||
// clean 403 is itself the proof that nothing downstream ran.
|
||||
func TestWrongRoleIsRefusedBeforeTheHandlerRuns(t *testing.T) {
|
||||
app := newApp()
|
||||
// One role from each group, so every case is covered by some wrong role.
|
||||
roles := map[int]string{1: "admin", 5: "miler", 6: "hub staff", 9: "customer"}
|
||||
|
||||
for _, r := range allRoutes() {
|
||||
for role, name := range roles {
|
||||
if role == r.wantRole {
|
||||
continue
|
||||
}
|
||||
// Admin roles 1/3/4 are interchangeable; only test a genuinely wrong one.
|
||||
if r.wantRole == 1 && (role == 3 || role == 4) {
|
||||
continue
|
||||
}
|
||||
t.Run(fmt.Sprintf("%s as %s", r.path, name), func(t *testing.T) {
|
||||
status, body := do(t, app, r.method, r.path, token(t, 1, role), r.body)
|
||||
if status != http.StatusForbidden && status != http.StatusUnauthorized {
|
||||
t.Errorf("a %s token on a role-%d route returned %d, want 403/401: %s",
|
||||
name, r.wantRole, status, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The refusal has to be machine-readable, not just a status code — the console
|
||||
// and the rider app both branch on the body.
|
||||
func TestRefusalBodyIsWellFormed(t *testing.T) {
|
||||
app := newApp()
|
||||
status, body := do(t, app, http.MethodGet, "/api/v1/miler/bases", token(t, 1, 1), "")
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("admin token on a miler route: got %d, want 403", status)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
|
||||
t.Fatalf("refusal body is not JSON: %q", body)
|
||||
}
|
||||
if parsed["success"] != false {
|
||||
t.Errorf(`refusal should carry "success": false, got %v`, parsed["success"])
|
||||
}
|
||||
if parsed["message"] != "insufficient permissions for this resource" {
|
||||
t.Errorf("unexpected refusal message: %v", parsed["message"])
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed or unsigned token must never be accepted as a valid session.
|
||||
func TestGarbageTokensAreRejected(t *testing.T) {
|
||||
app := newApp()
|
||||
for _, tok := range []string{
|
||||
"not-a-jwt",
|
||||
"eyJhbGciOiJub25lIn0.eyJyb2xlaWQiOjV9.", // alg:none, roleid 5
|
||||
"",
|
||||
} {
|
||||
status, _ := do(t, app, http.MethodGet, "/api/v1/miler/bases", tok, "")
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("token %q returned %d, want 401", tok, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A token signed with the wrong secret must not open a session — the check that
|
||||
// stops a token minted elsewhere from being trusted here.
|
||||
func TestTokenSignedWithAnotherSecretIsRejected(t *testing.T) {
|
||||
app := newApp()
|
||||
foreign, err := utils.GenerateToken(1, "x@y.z", 5, 0, 1001, "a-different-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status, _ := do(t, app, http.MethodGet, "/api/v1/miler/bases", foreign, ""); status != http.StatusUnauthorized {
|
||||
t.Errorf("foreign-signed token returned %d, want 401", status)
|
||||
}
|
||||
}
|
||||
|
||||
// `/miler/bases` is static and `/miler/consignments/:consignmentid` is dynamic.
|
||||
// Fiber matches in registration order, so a param route registered first would
|
||||
// swallow a static sibling — the bug the Orders route table has a comment about.
|
||||
func TestStaticRoutesAreNotShadowedByParamRoutes(t *testing.T) {
|
||||
app := newApp()
|
||||
|
||||
// Probed with a token of the WRONG role on purpose. A 403 proves the request
|
||||
// matched this route and reached its role gate; a 404 would mean it matched
|
||||
// nothing, which is how a param route swallowing a static sibling shows up.
|
||||
// Using the right role instead would enter the handler and hit the database,
|
||||
// which is not what this file tests.
|
||||
cases := []struct {
|
||||
path string
|
||||
wrongRole int
|
||||
}{
|
||||
{"/api/v1/miler/bases", 1}, // static, sits beside /consignments/:id
|
||||
{"/api/v1/hub/inbound/expected", 5}, // static, sits beside /inbound/:id/reconcile
|
||||
{"/api/v1/miler/consignments/logs", 1}, // static, sits beside /consignments/:id
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.path, func(t *testing.T) {
|
||||
status, body := do(t, app, http.MethodGet, c.path, token(t, 1, c.wrongRole), "")
|
||||
if status == http.StatusNotFound {
|
||||
t.Fatalf("404 — the route is shadowed or unregistered: %s", body)
|
||||
}
|
||||
if status != http.StatusForbidden {
|
||||
t.Errorf("got %d, want 403 (matched the route, refused the role): %s", status, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user