Web App Testing – Authentication Middleware

Agar suatu halaman hanya dapat diakses oleh authenticated user, kita akan gunakan middleware.

Buka file cmd/web/middleware.go, lalu tambahkan fungsi auth() seperti berikut:

package main

import (
	"context"
	"fmt"
	"net"
	"net/http"
)

type contextKey string

const contextUserKey contextKey = "user_ip"

func (app *application) ipFromContext(ctx context.Context) string {
	return ctx.Value(contextUserKey).(string)
}

func (app *application) addIPToContext(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var ctx = context.Background()
		// get the ip (as accurately as possible)
		ip, err := getIP(r)
		if err != nil {
			ip, _, _ = net.SplitHostPort(r.RemoteAddr)
			if len(ip) == 0 {
				ip = "unknown"
			}
			ctx = context.WithValue(r.Context(), contextUserKey, ip)
		} else {
			ctx = context.WithValue(r.Context(), contextUserKey, ip)
		}
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func getIP(r *http.Request) (string, error) {
	ip, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		return "unknown", err
	}

	userIP := net.ParseIP(ip)
	if userIP == nil {
		return "", fmt.Errorf("userip: %q is not IP:port", r.RemoteAddr)
	}

	forward := r.Header.Get("X-Forwarded-For")
	if len(forward) > 0 {
		ip = forward
	}

	if len(ip) == 0 {
		ip = "forward"
	}

	return ip, nil
}

func (app *application) auth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !app.Session.Exists(r.Context(), "user") {
			app.Session.Put(r.Context(), "error", "Login first")
			http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
			return
		}
		next.ServeHTTP(w, r)
	})
}

Kemudian registrasikan middleware pada cmd/web/routes.go

package main

import (
	"net/http"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
)

func (app *application) routes() http.Handler {
	mux := chi.NewRouter()

	//register middleware
	mux.Use(middleware.Recoverer)
	mux.Use(app.addIPToContext)
	mux.Use(app.Session.LoadAndSave) //middleware yang disediakan dari session manager

	//register routes
	mux.Get("/", app.Home)
	mux.Post("/login", app.Login)

	//gunakan middleware auth untuk route user profile
	mux.Route("/user", func(mux chi.Router) {
		mux.Use(app.auth)
		mux.Get("/profile", app.Profile)
	})

	//static assets

	fileServer := http.FileServer(http.Dir("./static"))
	mux.Handle("/static/*", http.StripPrefix("/static/", fileServer))

	return mux
}

Jika kita coba akses http://localhost:8080/user/profile tanpa melakukan login, sesuai ekspektasi, kita akan diredirect ke halaman login dengan message Login first.

Selanjutnya kita buat fungsi test, buka file cmd/web/middleware_test.go, lalu tambahkan fungsi Test_app_auth() seperti berikut.

package main

import (
	"context"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"webapp/pkg/data"
)

func Test_application_addIPToContext(t *testing.T) {
	tests := []struct {
		headerName   string
		heaaderValue string
		addr         string
		emptyaddr    bool
	}{
		{"", "", "", false},
		{"", "", "", true},
		{"X-Forwarded-For", "192.163.1.3", "", false},
		{"", "", "hello:skillplus", false},
	}

	//menggunakan environment variable
	//var app application

	//create dummy handler
	nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		//check whether value exists in the context
		val := r.Context().Value(contextUserKey)
		if val == nil {
			t.Error(contextUserKey, "not present")
		}

		//make sure get the string back
		ip, ok := val.(string)
		if !ok {
			t.Error("not string")
		}
		t.Log(ip)
	})

	for _, e := range tests {
		//create handler for test
		handlerToTest := app.addIPToContext(nextHandler)

		req := httptest.NewRequest("GET", "http://testing/", nil)

		if e.emptyaddr {
			req.RemoteAddr = ""
		}

		if len(e.headerName) > 0 {
			req.Header.Add(e.headerName, e.heaaderValue)
		}

		if len(e.addr) > 0 {
			req.RemoteAddr = e.addr
		}

		handlerToTest.ServeHTTP(httptest.NewRecorder(), req)
	}
}

func Test_application_ipFromContext(t *testing.T) {
	//menggunakan environment variable
	//var app application

	//get a context
	ctx := context.Background()

	//put somtehing in the context
	ctx = context.WithValue(ctx, contextUserKey, "random")

	//call the function
	ip := app.ipFromContext(ctx)

	//perform test
	if !strings.EqualFold("random", ip) {
		t.Error("wrong value returned from context")
	}
}

func Test_app_auth(t *testing.T) {
	nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

	})

	var tests = []struct {
		name   string
		isAuth bool
	}{
		{"logged in", true},
		{"not logged in", false},
	}

	for _, e := range tests {
		handlerToTest := app.auth(nextHandler)
		req := httptest.NewRequest("GET", "http://testing", nil)
		req = addCtxSessToReq(req, app)
		if e.isAuth {
			app.Session.Put(req.Context(), "user", data.User{ID: 1})

		}

		rr := httptest.NewRecorder()
		handlerToTest.ServeHTTP(rr, req)

		if e.isAuth && rr.Code != http.StatusOK {
			t.Errorf("%s: expected status code of 200 but got %d", e.name, rr.Code)
		}

		if !e.isAuth && rr.Code != http.StatusTemporaryRedirect {
			t.Errorf("%s: expected status code of 307 but got %d", e.name, rr.Code)
		}

	}
}

Jika kita jalankan test, sesuai ekspektasi, test berhasil.

cmd\web $ go test -v -run Test_app_auth

2022/10/25 17:55:16 Connected to Postgres!
=== RUN   Test_app_auth        
--- PASS: Test_app_auth (0.00s)
PASS
ok      webapp/cmd/web  0.685s

Sampai disini kita sudah menambahkan middleware authentication untuk mencegah halaman tertentu diakses tanpa credentials. Kita juga sudah menambahkan fungsi test untuk middleware tersebut.

Berikutnya kita akan melakukan houskeeping dengan menambahkan route “/user/profile” pada cmd/web/routes_test.go

package main

import (
	"net/http"
	"strings"
	"testing"

	"github.com/go-chi/chi/v5"
)

func Test_application_routes(t *testing.T) {
	var registered = []struct {
		route  string
		method string
	}{
		{"/", "GET"},
		{"/login", "POST"},
		{"/user/profile", "GET"},
		{"/static/*", "GET"},
	}

	mux := app.routes()

	chiRoutes := mux.(chi.Routes)

	for _, route := range registered {
		//check if the route exists
		if !routeExists(route.route, route.method, chiRoutes) {
			t.Errorf("route %s is not registered", route.route)
		}
	}
}

func routeExists(testRoute, testMethod string, chiRoutes chi.Routes) bool {
	found := false

	_ = chi.Walk(chiRoutes, func(method string, route string, handler http.Handler, middleware ...func(http.Handler) http.Handler) error {
		if strings.EqualFold(method, testMethod) && strings.EqualFold(route, testRoute) {
			found = true
		}
		return nil
	})

	return found
}

Housekeeping selanjutnya adalah, memperbaiki Test_application_handlers pada cmd/web/handlers_test.go agar memenuhi url yang harus ditest.

Kita akan mengubah struct table test, serta menambahkan data pada table test dan menambahkan test server kedua untuk melakukan pemeriksaan last status code , Berikut code setelah dimodifikasi.

package main

import (
	"context"
	"crypto/tls"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"
)

func Test_application_handlers(t *testing.T) {
	var theTests = []struct {
		name                  string
		url                   string
		expectedStatusCode    int
		expectedURL           string
		expectedFirstStatCOde int
	}{
		{"home", "/", http.StatusOK, "/", http.StatusOK},
		{"404", "/qwerty", http.StatusNotFound, "/qwerty", http.StatusNotFound},
		{"profile", "/user/profile", http.StatusOK, "/", http.StatusTemporaryRedirect},
	}

	routes := app.routes()

	//create a test server
	ts := httptest.NewTLSServer(routes)
	defer ts.Close()

	//create test server 2 untuk mendapatkan http statuscode pertama.
	tr := &http.Transport{
		TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
	}

	client := &http.Client{
		Transport: tr,
		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			return http.ErrUseLastResponse
		},
	}

	//testing
	for _, e := range theTests {
		resp, err := ts.Client().Get(ts.URL + e.url)
		if err != nil {
			t.Log(err)
			t.Fatal(err)
		}

		if resp.StatusCode != e.expectedStatusCode {
			t.Errorf("for %s: expected status %d, but got %d", e.name, e.expectedStatusCode, resp.StatusCode)
		}

		if resp.Request.URL.Path != e.expectedURL {
			t.Errorf("%s: expected final url of %s but got %s", e.name, e.expectedURL, resp.Request.URL.Path)
		}

		//check jika non credentials user masuk maka diharapkan statuscode adalah StatusTemporaryRedirect
		resp2, _ := client.Get(ts.URL + e.url)
		if resp2.StatusCode != e.expectedFirstStatCOde {
			t.Errorf("%s: expected first return stat code %d but go %d", e.name, e.expectedFirstStatCOde, resp2.StatusCode)
		}
	}

}

func TestAppHome(t *testing.T) {
	var tests = []struct {
		name         string
		putInSession string
		expctedHTML  string
	}{
		{"First Visit", "", "<small>Session:"},
		{"Second Visit", "skillplus", "<small>Session: skillplus"},
	}

	for _, e := range tests {
		req, _ := http.NewRequest("GET", "/", nil)

		req = addCtxSessToReq(req, app)
		_ = app.Session.Destroy(req.Context())

		if e.putInSession != "" {
			app.Session.Put(req.Context(), "test", e.putInSession)
		}

		rr := httptest.NewRecorder()

		handler := http.HandlerFunc(app.Home)
		handler.ServeHTTP(rr, req)

		//check status code
		if rr.Code != http.StatusOK {
			t.Errorf("TestAppHome: returned wrong status code, expected 200 but got %d", rr.Code)
		}

		body, _ := io.ReadAll(rr.Body)
		if !strings.Contains(string(body), e.expctedHTML) {
			t.Errorf("%s: did not find %s in response body", e.name, e.expctedHTML)
		}
	}
}

func TestApp_renderWBadTemplate(t *testing.T) {
	templatePath = "./testdata/"

	req, _ := http.NewRequest("GET", "/", nil)
	req = addCtxSessToReq(req, app)
	rr := httptest.NewRecorder()

	err := app.render(rr, req, "bad.page.gohtml", &TemplateData{})

	if err == nil {
		t.Error("Expected error from bad template, but did not")
	}
	templatePath = "./../../templates/"
}

func addCtxSessToReq(req *http.Request, app application) *http.Request {
	req = req.WithContext(context.WithValue(req.Context(), contextUserKey, "unknown"))
	ctx, _ := app.Session.Load(req.Context(), req.Header.Get("X-Session"))

	return req.WithContext(ctx)
}

func Test_app_Login(t *testing.T) {
	var tests = []struct {
		name             string
		postedData       url.Values
		expectedStatCode int
		expectedLoc      string
	}{
		{
			name:             "Valid Login",
			postedData:       url.Values{"email": {"admin@example.com"}, "password": {"secret"}},
			expectedStatCode: http.StatusSeeOther,
			expectedLoc:      "/user/profile",
		},
		{
			name:             "Missing form data",
			postedData:       url.Values{"email": {""}, "password": {""}},
			expectedStatCode: http.StatusSeeOther,
			expectedLoc:      "/",
		},
		{
			name:             "User not found",
			postedData:       url.Values{"email": {"me@skillplus.web.id"}, "password": {"123"}},
			expectedStatCode: http.StatusSeeOther,
			expectedLoc:      "/",
		},
		{
			name:             "Bad Credentials",
			postedData:       url.Values{"email": {"admin@example.com"}, "password": {"123"}},
			expectedStatCode: http.StatusSeeOther,
			expectedLoc:      "/",
		},
	}

	for _, e := range tests {
		req, _ := http.NewRequest("POST", "/login", strings.NewReader(e.postedData.Encode()))
		req = addCtxSessToReq(req, app)
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

		rr := httptest.NewRecorder()
		handler := http.HandlerFunc(app.Login)
		handler.ServeHTTP(rr, req)

		if rr.Code != e.expectedStatCode {
			t.Errorf("%s: returned wrong stat code: expected %d, but got %d", e.name, e.expectedStatCode, rr.Code)
		}

		actualLoc, err := rr.Result().Location()
		if err == nil {
			if actualLoc.String() != e.expectedLoc {
				t.Errorf("%s: expected location %s, but got %s", e.name, e.expectedLoc, actualLoc.String())
			}
		} else {
			t.Errorf("%s: no location header set", e.name)
		}

	}
}

JIka kita jalankan test dari directory cmd/web/

cmd\web> go test -v -run Test_application_handlers

2022/10/26 16:52:35 Connected to Postgres!
=== RUN   Test_application_handlers
--- PASS: Test_application_handlers (0.25s)
PASS
ok      webapp/cmd/web  1.258s

Sesuai ekspektasi, test berhasil dan sudah mengcover route yang ada.

Sharing is caring:

Leave a Comment