Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

No (Documented) way to test protected handlers #2676

Open
3 tasks done
apuatcfbd opened this issue Aug 23, 2024 · 2 comments
Open
3 tasks done

No (Documented) way to test protected handlers #2676

apuatcfbd opened this issue Aug 23, 2024 · 2 comments

Comments

@apuatcfbd
Copy link

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

  • Dependencies installed
  • No typos
  • Searched existing issues and docs

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: "[email protected]",
			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
Copy link
Contributor

aldas commented Aug 23, 2024

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

@apuatcfbd
Copy link
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants