Files
doormile_backend/routes/routes_logistics_test.go

258 lines
9.0 KiB
Go

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)
}
})
}
}