labstack / echo

High performance, minimalist Go web framework
https://echo.labstack.com
MIT License
29.46k stars 2.21k forks source link

No (Documented) way to test protected handlers #2676

Open apuatcfbd opened 3 weeks ago

apuatcfbd commented 3 weeks ago

Issue Description

Doc has a Testing section. Examples there only works with public/ unprotected routes/ handlers. In a real-world app, most of the routes are protected. Same for my case. I'm using echojwt to protect routes. Unfortunately, I've failed to test those protected routes even after googling.

Checklist

Expected behaviour

Need way/ (Doc) examples to be able to test protected handlers.

Actual behaviour

No examples/ guidelines for testing protected handlers in the doc

Steps to reproduce

  1. Create a new echo app
  2. Create 1 protected route with echojwt
  3. Tty to write a test for the protected handler.

Working code to debug

I don't know what this section is for

// main.go

package main

import (
    "fmt"
    "github.com/fatih/color"
    "github.com/gookit/event"
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
    "github.com/user/proj/config"
    "github.com/user/proj/database"
    "github.com/user/proj/database/seeders"
    "github.com/user/proj/internal/bootstrap"
    "github.com/user/proj/internal/hs"
    middleware2 "github.com/user/proj/middleware"
    "github.com/user/proj/routes"
    "log"
    "net/http"
    "sort"
)

func init() {
    // connect to DB
    database.ConnectDB()

    // register custom serializers (need this registration only if using this in tags)
    //schema.RegisterSerializer("settingValue", serializers.SettingValue{})

    // register events
    bootstrap.RegisterEvents()
}

func main() {
    // close async event chan
    defer func() {
        err := event.CloseWait()
        if err != nil {
            log.Fatal("Event close err:", err)
        }
    }()

    e := echo.New()

    isLocal := config.EnvDebug()

    e.Debug = isLocal
    e.Renderer = hs.GetTemplateMap()
    e.Validator = &bootstrap.CustomValidator{}

    e.Use(
        middleware.BodyLimit("30M"),
        middleware.GzipWithConfig(middleware.GzipConfig{
            Level: 5,
        }),
        middleware.LoggerWithConfig(middleware.LoggerConfig{
            //Format: "➡ ${method} ${uri} - ${status}\n",
            Format: "➡ ${method}: ${host} ref:${referer} remote:${remote_ip} ${uri} - ${status}\n",
        }),
        middleware.RateLimiterWithConfig(middleware2.ThrottleConfig),
        middleware.Recover(),
        middleware.CORSWithConfig(middleware.CORSConfig{
            AllowOrigins: []string{config.EnvUrlUi(), config.EnvUrlAdmin()},
            AllowMethods: []string{
                http.MethodGet, http.MethodHead, http.MethodOptions,
                http.MethodPatch, http.MethodPost, http.MethodDelete,
            },
        }),
    )

    // serve static
    // like: http://domail.tld/s/path/file.ext
    e.Static("/s/", "storage")

    // home route (public)
    e.GET("/", func(ctx echo.Context) error {
        return ctx.String(http.StatusOK, "Okay")
    })
    e.GET("/hc", func(ctx echo.Context) error {
        return ctx.String(http.StatusOK, "OK")
    })

    // setup router
    routes.SetupRoutes(e)

    log.Fatalln(
        e.Start(":3000"),
    )
}
// routes.go
package routes

import (
    "github.com/labstack/echo/v4"
    aclroutes "github.com/user/proj/internal/modules/acl/routes"
    authRoutes "github.com/user/proj/internal/modules/auth/routes"
    "github.com/user/proj/middleware"
    "github.com/user/proj/pkg/router"
)

// register module routes here
// like - [route-segment]: module.Routes
var routes = router.RouteList{
    "/auth":         authRoutes.Routes, //-----------> these routes are protected with echojwt
    "/acl":          aclroutes.Routes,
}

func SetupRoutes(e *echo.Echo) {
    routeGroup := e.Group("/v1")

    router.SetupRoutes(
        routeGroup,
        routes,
        // middlewares that'll apply in protected routes
        middleware.JwtAuth(),
        middleware.Acl,
    )
}

// RouteList list of all app routes
type RouteList = map[string]ModuleRoutes

// SetupRoutes registers all public routes & private routs with given middlewares
func SetupRoutes(routeGroup *echo.Group, routes RouteList, protectedMiddlewares ...echo.MiddlewareFunc) {
    // versioning
    registerRoutes(routes, routeGroup, protectedMiddlewares)
}

// registers private & public routes
func registerRoutes(routes RouteList, group *echo.Group, protectedMiddlewares []echo.MiddlewareFunc) {
    // setup public
    for segment, r := range routes {
        r.SetupPublic(group, segment)
    }

    // below this all routes will be private due to JwtAuth
    group.Use(protectedMiddlewares...)

    for segment, r := range routes {
        r.SetupPrivate(group, segment)
    }
}

Middlewares

// jwtAuth.go
package middleware

import (
    echojwt "github.com/labstack/echo-jwt/v4"
    "github.com/labstack/echo/v4"
    "github.com/user/proj/config"
)

func JwtAuth() echo.MiddlewareFunc {
    return echojwt.WithConfig(echojwt.Config{
        SigningKey: []byte(config.EnvKey()),
    })
}

// acl.go
package middleware

import (
    "github.com/labstack/echo/v4"
    "github.com/user/proj/config"
    "github.com/user/proj/internal/hs"
    authservice "github.com/user/proj/internal/modules/auth/service"
    "github.com/user/proj/internal/policies"
)

func Acl(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        // get user
        token, ok := hs.GetToken(c)
        if !ok {
            return policies.UnauthorizedResponse(c)
        }

        user, err := authservice.GetAuthUser(token)
        if err != nil {
            return policies.UnauthorizedResponse(c)
        }

        c.Set(config.AuthUserKeyName, user)

        return next(c)
    }
}

// hs.GetToken (helper fn)
func GetToken(c echo.Context) (token *jwt.Token, ok bool) {
    token, ok = c.Get("user").(*jwt.Token)
    if !ok {
        log.Println("JWT token missing or invalid")
    }
    return
}

The handler func

func AuthUser(c echo.Context) error {
    user := auth.ReqGetUser(c)
    if user == nil { // -------> user is nil so getting 401 in the test
        return policies.UnauthorizedResponse(c)
    }

    return c.JSON(http.StatusOK, hs.Res(hs.ResData{
        Status: true,
        D:      user,
    }))
}

// in a helper file
func ReqGetUser(c echo.Context) *model.User {
    u := c.Get(config.AuthUserKeyName)

    user, ok := u.(model.User)
    if !ok {
        return nil
    }

    return &user
}

The test

func TestAuthUser(t *testing.T) {
    e := initEcho()

    doSignup := func() (token string, user model.User) {
        input := struct {
            Name  string `json:"name"`
            Email string `json:"email"`
            Pass  string `json:"password"`
        }{
            Name:  "Test User for " + t.Name(),
            Email: "admin2@gmail.com",
            Pass:  "123456",
        }

        // prepare input as string
        p, er := jsonutil.EncodeString(input)
        if er != nil {
            th.Fatalf(t, "Failed to encode json: %s", er)
        }

        // attach payload to request
        req := httptest.NewRequest(http.MethodPost, "/v1/auth/sign-up", strings.NewReader(p))
        req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)

        // create response writer & context
        rec := httptest.NewRecorder()
        c := e.NewContext(req, rec)

        _ = SignUp(c)

        if rec.Code != http.StatusCreated {
            th.Fatalf(t, "Failed to signup")
        }

        // decode login response

        type loginResponse struct {
            Data struct {
                Token string     `json:"token"`
                User  model.User `json:"user"`
            } `json:"d"`
        }
        res := new(loginResponse)

        if err := json.Unmarshal(rec.Body.Bytes(), res); err != nil {
            th.Fatal(t, "Failed to decode login response:", err)
        }

        if res.Data.Token == "" {
            th.Fatal(t, "Login response missing token")
        }

        return res.Data.Token, res.Data.User
    }

    // login
    authToken, user := doSignup() // --> this & the t.Cleanup below works fine

    // at last delete the user
    t.Cleanup(func() {
        err := database.DB.Delete(&user).Error
        if err != nil {
            th.Fatal(t, "Failed to delete created user:", err)
        }
    })

    // get auth user
    tests := []struct {
        name        string
        token       string
        wantResCode int
    }{
        {
            name:        "success with valid token",
            token:       authToken,
            wantResCode: http.StatusOK,
        },
        {
            name:        "fail with invalid token",
            token:       "authToken",
            wantResCode: http.StatusUnauthorized,
        },
    }

    // --> Is this necessary here? Actual app has this registered for all protected routes
    e.Use(
        middleware2.JwtAuth(),
        middleware2.Acl,
    )

    for _, tt := range tests {
        // request with token
        req := httptest.NewRequest(http.MethodGet, "/v1/auth/me", nil)
        req.Header.Set(echo.HeaderAuthorization, "Bearer "+tt.token)

        // create response writer & context
        rec := httptest.NewRecorder()
        //c := e.NewContext(req, rec)

        t.Run(tt.name, func(t *testing.T) {
            //_ = AuthUser(c) // --> Case1: doesn't triggers any middleware
            e.ServeHTTP(rec, req) // --> Case2: triggers middleware but fails

            // --> Case1: Fails with 401 as the middlewares not triggered so user is missing & this is a protected route
            // --> Case2: Fails with 404 (I might messed up something here)
            if rec.Code != tt.wantResCode {
                th.Errorf(t, "Response Code %d want %d for user '%s'", rec.Code, tt.wantResCode, user.Email)
            }
        })
    }
}

TL,DR: I'm new in Go & Echo. So please forgive my silly mistakes, I welcome any suggestion/ resource to learn more.

Please don't hesitate to ask any questions regarding this topic. I'm open to do what it takes to sort out this issue :).

Version/commit

go 1.22.5 github.com/labstack/echo-jwt/v4 v4.2.0 github.com/labstack/echo/v4 v4.12.0

aldas commented 3 weeks ago

Have you checked echojwt tests for examples? For example: https://github.com/labstack/echo-jwt/blob/main/jwt_extranal_test.go

apuatcfbd commented 3 weeks ago

Thank you for your response, @aldas. I didn't check that earlier. After reviewing it, I think that is a bit different than the docs way, where we need to start the server. I understand this might be necessary to execute registered routes & middleware. I got some insight from that test you mentioned and managed to serve the request with e.ServeHTTP fn. Above in "The test" inside t.Run (for Case2) I was getting 404 because in this new echo I do not have that route (/v1/auth/me) which is used in the request (httptest.NewRequest). I've solved the issue by adding the route, which looks like the following

func TestAuthUser(t *testing.T) {
    e := initEcho()

    doSignup := func() (token string, user model.User) {
        // sign up a new user ...

                // return token & user 
        return response.Token, res.Data.User
    }

    // login
    authToken, user := doSignup()

    // at last delete the user
    t.Cleanup(func() {
        err := database.DB.Delete(&user).Error
        if err != nil {
            th.Fatal(t, "Failed to delete created user:", err)
        }
    })

    // get auth user
    tests := []struct {
        name        string
        token       string
        wantResCode int
    }{
        {
            name:        "success with valid token",
            token:       authToken,
            wantResCode: http.StatusOK,
        },
        {
            name:        "fail with invalid token",
            token:       "authToken",
            wantResCode: http.StatusUnauthorized,
        },
    }

        // [---KEY POINT 1---] register necessary middleware (which is necessary for the hander)
        // here, for my case as the handler is under a protected route, I need following 2 middleware
    e.Use(
        middleware2.JwtAuth(),
        middleware2.Acl,
    )

    // [---KEY POINT 2---] register the route using the handler
    e.GET("/v1/auth/me", AuthUser)

    for _, tt := range tests {
        // request with token
        req := httptest.NewRequest(http.MethodGet, "/v1/auth/me", nil)
        req.Header.Set(echo.HeaderAuthorization, "Bearer "+tt.token)

        // create response writer & context
        rec := httptest.NewRecorder()

        t.Run(tt.name, func(t *testing.T) {
            // [---KEY POINT 3---] serve the request, this triggers registered routes & middlewares
            e.ServeHTTP(rec, req)

            if rec.Code != tt.wantResCode {
                th.Errorf(t, "Response Code %d want %d for user '%s'", rec.Code, tt.wantResCode, user.Email)
            }
        })
    }
}

Now the tests are passing

✅ PASS: TestAuthUser (0.89s)
    ✅ PASS: TestAuthUser/success_with_valid_token (0.00s)
    ✅ PASS: TestAuthUser/fail_with_invalid_token (0.00s)

I think this (or better) example for testing protected routes/ handlers should be added in the docs. That'll help newcomers.