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