package routes_test import ( "net/http" "os" "strconv" "strings" "testing" "doormile/db" "doormile/internal/testpg" "doormile/models" "doormile/utils" ) // A client (tenant) login can open the console's Fleet Ops menu, so every // create/edit/delete behind it, and every internal list, must refuse a client // token on the server, not just be hidden in the UI. var staffOnlyFleetRoutes = []struct{ method, path string }{ {http.MethodPost, "/api/v1/admin/tenants"}, {http.MethodGet, "/api/v1/admin/users"}, {http.MethodPost, "/api/v1/admin/users"}, {http.MethodPut, "/api/v1/admin/users/1"}, {http.MethodDelete, "/api/v1/admin/users/1"}, {http.MethodPost, "/api/v1/admin/hubs"}, {http.MethodPut, "/api/v1/admin/hubs/1"}, {http.MethodDelete, "/api/v1/admin/hubs/1"}, {http.MethodPost, "/api/v1/admin/vehicles"}, {http.MethodPut, "/api/v1/admin/vehicles/1"}, {http.MethodDelete, "/api/v1/admin/vehicles/1"}, {http.MethodGet, "/api/v1/admin/tripsheets"}, {http.MethodPost, "/api/v1/admin/tripsheets"}, {http.MethodGet, "/api/v1/admin/tripsheets/1"}, {http.MethodPost, "/api/v1/admin/tripsheets/1/items"}, {http.MethodDelete, "/api/v1/admin/tripsheets/1/items/1"}, {http.MethodPut, "/api/v1/admin/tripsheets/1/dispatch"}, {http.MethodPut, "/api/v1/admin/tripsheets/1/arrive"}, {http.MethodGet, "/api/v1/admin/competitor-branches"}, {http.MethodPost, "/api/v1/admin/competitor-branches"}, {http.MethodPut, "/api/v1/admin/competitor-branches/1"}, {http.MethodDelete, "/api/v1/admin/competitor-branches/1"}, {http.MethodGet, "/api/v1/admin/carrier-pricing"}, {http.MethodPost, "/api/v1/admin/carrier-pricing"}, {http.MethodPut, "/api/v1/admin/carrier-pricing/1"}, {http.MethodDelete, "/api/v1/admin/carrier-pricing/1"}, {http.MethodPost, "/api/v1/admin/exceptions"}, {http.MethodPut, "/api/v1/admin/exceptions/1/status"}, } func TestFleetOpsWritesRefuseAClientLogin(t *testing.T) { app := onboardingApp() client := consoleToken(t, "ops@client.test", 3, 42) for _, r := range staffOnlyFleetRoutes { code, body := do(t, app, r.method, r.path, client, `{}`) if code != http.StatusForbidden || !strings.Contains(body, "Doormile staff only") { t.Errorf("%s %s as a client = %d %s, want 403", r.method, r.path, code, body) } } } func TestFleetOpsGuardsLetStaffThrough(t *testing.T) { app := onboardingApp() staff := consoleToken(t, "ops@doormile.com", 1, 0) for _, r := range staffOnlyFleetRoutes { // Past the guard the handler runs (and, with no database, may fail); // all that matters here is that the guard did not refuse. func() { defer func() { _ = recover() }() if code, body := do(t, app, r.method, r.path, staff, `{}`); strings.Contains(body, "Doormile staff only") { t.Errorf("%s %s as staff = %d %s, the guard refused", r.method, r.path, code, body) } }() } } // Hubs are scoped to the client's own city, from the server. Postgres-gated // like the other route tests; the DSN must be a THROWAWAY database. func TestClientSeesOnlyItsOwnCityHubs(t *testing.T) { dsn := os.Getenv("REGISTRY_TEST_DSN") if dsn == "" { t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres hub scoping test") } gdb := testpg.Open(t, dsn, "client_fleetops_routes_test") all := []any{&models.Hub{}, &models.AppUser{}, &models.AppLocation{}} if err := gdb.Migrator().DropTable(all...); err != nil { t.Fatal(err) } if err := gdb.AutoMigrate(all...); err != nil { t.Fatal(err) } prev := db.DB db.DB = gdb t.Cleanup(func() { db.DB = prev }) gdb.Create(&models.AppLocation{Applocationid: 1, Applocationname: "Coimbatore", Status: "Active"}) gdb.Create(&models.AppLocation{Applocationid: 2, Applocationname: "Chennai", Status: "Active"}) cbe := models.Hub{Hubname: "Coimbatore Neptune Hub", Hubtype: "delivery_hub", Applocationid: 1, Status: "Active"} chn := models.Hub{Hubname: "Chennai Guindy Hub", Hubtype: "delivery_hub", Applocationid: 2, Status: "Active"} gdb.Create(&cbe) gdb.Create(&chn) withCity := models.AppUser{Authname: "Priya", Email: "ops@peelamedu.test", Contactno: "9876543210", Password: "x", Roleid: 3, Applocationid: 1} noCity := models.AppUser{Authname: "Nocity", Email: "ops@nocity.test", Contactno: "9876543211", Password: "x", Roleid: 3} gdb.Create(&withCity) gdb.Create(&noCity) app := onboardingApp() mint := func(uid int, email string, tenant int) string { tok, err := utils.GenerateToken(uid, email, 3, tenant, 1, jwtSecret) if err != nil { t.Fatal(err) } return tok } client := mint(withCity.Userid, withCity.Email, 42) code, body := do(t, app, http.MethodGet, "/api/v1/admin/hubs", client, "") if code != 200 || !strings.Contains(body, "Coimbatore Neptune Hub") || strings.Contains(body, "Chennai Guindy Hub") { t.Fatalf("client hub list = %d %s, want Coimbatore only", code, body) } // Asking for another city by query string changes nothing. if _, body := do(t, app, http.MethodGet, "/api/v1/admin/hubs?applocationid=2", client, ""); strings.Contains(body, "Chennai Guindy Hub") { t.Fatalf("?applocationid=2 leaked another city: %s", body) } if code, _ := do(t, app, http.MethodGet, "/api/v1/admin/hubs/"+strconv.Itoa(chn.Hubid), client, ""); code != 404 { t.Fatalf("another city's hub detail = %d, want 404", code) } if code, _ := do(t, app, http.MethodGet, "/api/v1/admin/hubs/"+strconv.Itoa(cbe.Hubid), client, ""); code != 200 { t.Fatalf("own city's hub detail = %d, want 200", code) } // A client with no city on file sees no hubs, never every hub. if _, body := do(t, app, http.MethodGet, "/api/v1/admin/hubs", mint(noCity.Userid, noCity.Email, 43), ""); strings.Contains(body, "Hub\"") { t.Fatalf("client without a city = %s, want an empty list", body) } // Staff still see every hub. _, body = do(t, app, http.MethodGet, "/api/v1/admin/hubs", consoleToken(t, "ops@doormile.com", 1, 0), "") if !strings.Contains(body, "Coimbatore Neptune Hub") || !strings.Contains(body, "Chennai Guindy Hub") { t.Fatalf("staff hub list = %s, want both cities", body) } }