258 lines
9.0 KiB
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)
|
|
}
|
|
})
|
|
}
|
|
}
|