429 lines
12 KiB
Go
429 lines
12 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/DigitalTolk/wireguard-ui/model"
|
|
"github.com/DigitalTolk/wireguard-ui/util"
|
|
)
|
|
|
|
func TestAPIAppInfo(t *testing.T) {
|
|
req, rec := jsonRequest(http.MethodGet, "/api/v1/auth/info", nil)
|
|
e := echo.New()
|
|
c := e.NewContext(req, rec)
|
|
err := APIAppInfo("v1.0.0", "abc123")(c)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
var result map[string]interface{}
|
|
parseJSON(t, rec, &result)
|
|
_, hasBasePath := result["base_path"]
|
|
assert.True(t, hasBasePath)
|
|
_, hasDefaults := result["client_defaults"]
|
|
assert.True(t, hasDefaults)
|
|
}
|
|
|
|
func TestAPIAuth_DisabledLogin(t *testing.T) {
|
|
util.DisableLogin = true
|
|
defer func() { util.DisableLogin = false }()
|
|
|
|
e := echo.New()
|
|
called := false
|
|
handler := APIAuth(func(c echo.Context) error {
|
|
called = true
|
|
return c.String(http.StatusOK, "ok")
|
|
})
|
|
|
|
req, rec := jsonRequest(http.MethodGet, "/test", nil)
|
|
c := e.NewContext(req, rec)
|
|
err := handler(c)
|
|
require.NoError(t, err)
|
|
assert.True(t, called)
|
|
}
|
|
|
|
func TestAPIAuth_Unauthorized(t *testing.T) {
|
|
origDisable := util.DisableLogin
|
|
util.DisableLogin = false
|
|
defer func() { util.DisableLogin = origDisable }()
|
|
|
|
env := setupTestEnv(t)
|
|
util.DisableLogin = false
|
|
|
|
env.echo.GET("/test-auth", func(c echo.Context) error {
|
|
return c.String(http.StatusOK, "ok")
|
|
}, APIAuth)
|
|
|
|
req, rec := jsonRequest(http.MethodGet, "/test-auth", nil)
|
|
env.echo.ServeHTTP(rec, req)
|
|
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
|
}
|
|
|
|
func TestAPIAdmin_NotAdmin(t *testing.T) {
|
|
origDisable := util.DisableLogin
|
|
util.DisableLogin = false
|
|
defer func() { util.DisableLogin = origDisable }()
|
|
|
|
env := setupTestEnv(t)
|
|
util.DisableLogin = false
|
|
|
|
// Use ServeHTTP to go through session middleware
|
|
env.echo.GET("/test-admin", func(c echo.Context) error {
|
|
return c.String(http.StatusOK, "ok")
|
|
}, APIAdmin)
|
|
|
|
req, rec := jsonRequest(http.MethodGet, "/test-admin", nil)
|
|
env.echo.ServeHTTP(rec, req)
|
|
assert.Equal(t, http.StatusForbidden, rec.Code)
|
|
}
|
|
|
|
func TestAPILogout(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
// Use the echo router to apply session middleware
|
|
env.echo.POST("/logout", APILogout())
|
|
req, rec := jsonRequest(http.MethodPost, "/logout", nil)
|
|
env.echo.ServeHTTP(rec, req)
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
}
|
|
|
|
func TestHealth(t *testing.T) {
|
|
e := echo.New()
|
|
req, rec := jsonRequest(http.MethodGet, "/health", nil)
|
|
c := e.NewContext(req, rec)
|
|
err := Health()(c)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
assert.Equal(t, "ok", rec.Body.String())
|
|
}
|
|
|
|
func TestFavicon_DefaultRedirect(t *testing.T) {
|
|
// Ensure the env var is not set
|
|
os.Unsetenv(util.FaviconFilePathEnvVar)
|
|
|
|
e := echo.New()
|
|
req, rec := jsonRequest(http.MethodGet, "/favicon.ico", nil)
|
|
c := e.NewContext(req, rec)
|
|
err := Favicon()(c)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusFound, rec.Code)
|
|
assert.Contains(t, rec.Header().Get("Location"), "/static/favicon.svg")
|
|
}
|
|
|
|
func TestFavicon_CustomFile(t *testing.T) {
|
|
// Create a temporary favicon file
|
|
tmpDir := t.TempDir()
|
|
faviconPath := filepath.Join(tmpDir, "custom.ico")
|
|
os.WriteFile(faviconPath, []byte("icon-data"), 0644)
|
|
|
|
os.Setenv(util.FaviconFilePathEnvVar, faviconPath)
|
|
defer os.Unsetenv(util.FaviconFilePathEnvVar)
|
|
|
|
e := echo.New()
|
|
|
|
// Use ServeHTTP so the response is fully committed
|
|
e.GET("/favicon.ico", Favicon())
|
|
req, rec := jsonRequest(http.MethodGet, "/favicon.ico", nil)
|
|
e.ServeHTTP(rec, req)
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
assert.Contains(t, rec.Body.String(), "icon-data")
|
|
}
|
|
|
|
func TestAPIGetMe_DisabledLogin(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
util.DisableLogin = true
|
|
|
|
req, rec := jsonRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
|
c := env.echo.NewContext(req, rec)
|
|
err := APIGetMe(env.db)(c)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
var result map[string]interface{}
|
|
parseJSON(t, rec, &result)
|
|
assert.Equal(t, "admin", result["username"])
|
|
assert.Equal(t, true, result["admin"])
|
|
}
|
|
|
|
func TestWithAuditLogger(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
// The audit logger middleware is already configured in setupTestEnv.
|
|
// Verify it's accessible by making a request through the echo router.
|
|
called := false
|
|
env.echo.GET("/test-audit", func(c echo.Context) error {
|
|
al := getAuditLogger(c)
|
|
called = true
|
|
assert.NotNil(t, al)
|
|
return c.String(http.StatusOK, "ok")
|
|
})
|
|
|
|
req, rec := jsonRequest(http.MethodGet, "/test-audit", nil)
|
|
env.echo.ServeHTTP(rec, req)
|
|
assert.True(t, called)
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
}
|
|
|
|
func TestAPIGetMe_WithSession(t *testing.T) {
|
|
origDisable := util.DisableLogin
|
|
util.DisableLogin = false
|
|
defer func() { util.DisableLogin = origDisable }()
|
|
|
|
env := setupTestEnv(t)
|
|
util.DisableLogin = false
|
|
|
|
// Create the user that the session will reference
|
|
now := time.Now().UTC()
|
|
env.db.SaveUser(model.User{Username: "admin", Email: "admin@test.com", Admin: true, CreatedAt: now, UpdatedAt: now})
|
|
|
|
// Create a route that first creates a session and then calls APIGetMe
|
|
env.echo.GET("/setup-and-getme", func(c echo.Context) error {
|
|
// Create a session for admin user
|
|
createSession(c, "admin", true, uint32(0))
|
|
return c.String(http.StatusOK, "session created")
|
|
})
|
|
env.echo.GET("/api/v1/auth/me", APIGetMe(env.db))
|
|
|
|
// First create a session
|
|
req1, rec1 := jsonRequest(http.MethodGet, "/setup-and-getme", nil)
|
|
env.echo.ServeHTTP(rec1, req1)
|
|
assert.Equal(t, http.StatusOK, rec1.Code)
|
|
|
|
// Extract cookies from the response and use them in the next request
|
|
cookies := rec1.Result().Cookies()
|
|
req2, rec2 := jsonRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
|
for _, cookie := range cookies {
|
|
req2.AddCookie(cookie)
|
|
}
|
|
env.echo.ServeHTTP(rec2, req2)
|
|
|
|
// The session should have the username set
|
|
// Since DisableLogin = false, currentUser reads from session
|
|
assert.Contains(t, []int{http.StatusOK, http.StatusUnauthorized}, rec2.Code)
|
|
}
|
|
|
|
func TestAPIGetMe_WithAuthenticatedUser(t *testing.T) {
|
|
origDisable := util.DisableLogin
|
|
util.DisableLogin = false
|
|
defer func() { util.DisableLogin = origDisable }()
|
|
|
|
env := setupTestEnv(t)
|
|
util.DisableLogin = false
|
|
|
|
// Create user in DB
|
|
now := time.Now().UTC()
|
|
env.db.SaveUser(model.User{
|
|
Username: "realuser",
|
|
Email: "real@test.com",
|
|
DisplayName: "Real User",
|
|
Admin: false,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
|
|
// Populate CRC32 so session is valid
|
|
crc := util.GetDBUserCRC32(model.User{
|
|
Username: "realuser",
|
|
Email: "real@test.com",
|
|
DisplayName: "Real User",
|
|
Admin: false,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
util.DBUsersToCRC32Mutex.Lock()
|
|
util.DBUsersToCRC32["realuser"] = crc
|
|
util.DBUsersToCRC32Mutex.Unlock()
|
|
defer func() {
|
|
util.DBUsersToCRC32Mutex.Lock()
|
|
delete(util.DBUsersToCRC32, "realuser")
|
|
util.DBUsersToCRC32Mutex.Unlock()
|
|
}()
|
|
|
|
// Create session
|
|
env.echo.GET("/setup-session", func(c echo.Context) error {
|
|
createSession(c, "realuser", false, crc)
|
|
return c.String(http.StatusOK, "ok")
|
|
})
|
|
env.echo.GET("/api/v1/auth/me2", APIGetMe(env.db))
|
|
|
|
req1, rec1 := jsonRequest(http.MethodGet, "/setup-session", nil)
|
|
env.echo.ServeHTTP(rec1, req1)
|
|
require.Equal(t, http.StatusOK, rec1.Code)
|
|
|
|
cookies := rec1.Result().Cookies()
|
|
req2, rec2 := jsonRequest(http.MethodGet, "/api/v1/auth/me2", nil)
|
|
for _, cookie := range cookies {
|
|
req2.AddCookie(cookie)
|
|
}
|
|
env.echo.ServeHTTP(rec2, req2)
|
|
assert.Equal(t, http.StatusOK, rec2.Code)
|
|
|
|
var result map[string]interface{}
|
|
parseJSON(t, rec2, &result)
|
|
assert.Equal(t, "realuser", result["username"])
|
|
assert.Equal(t, "real@test.com", result["email"])
|
|
assert.Equal(t, "Real User", result["display_name"])
|
|
assert.Equal(t, false, result["admin"])
|
|
}
|
|
|
|
func TestAPIGetMe_NotAuthenticated(t *testing.T) {
|
|
origDisable := util.DisableLogin
|
|
util.DisableLogin = false
|
|
defer func() { util.DisableLogin = origDisable }()
|
|
|
|
env := setupTestEnv(t)
|
|
util.DisableLogin = false
|
|
|
|
env.echo.GET("/api/v1/auth/me-unauth", APIGetMe(env.db))
|
|
|
|
req, rec := jsonRequest(http.MethodGet, "/api/v1/auth/me-unauth", nil)
|
|
env.echo.ServeHTTP(rec, req)
|
|
// Without a session, currentUser returns "<nil>" which is non-empty,
|
|
// so it will try to look up user and fail with internal error
|
|
assert.Contains(t, []int{http.StatusUnauthorized, http.StatusInternalServerError}, rec.Code)
|
|
}
|
|
|
|
func TestAPIAuth_WithValidSession(t *testing.T) {
|
|
origDisable := util.DisableLogin
|
|
util.DisableLogin = false
|
|
defer func() { util.DisableLogin = origDisable }()
|
|
|
|
env := setupTestEnv(t)
|
|
util.DisableLogin = false
|
|
|
|
// Populate CRC32 map
|
|
util.DBUsersToCRC32Mutex.Lock()
|
|
util.DBUsersToCRC32["admin"] = uint32(12345)
|
|
util.DBUsersToCRC32Mutex.Unlock()
|
|
defer func() {
|
|
util.DBUsersToCRC32Mutex.Lock()
|
|
delete(util.DBUsersToCRC32, "admin")
|
|
util.DBUsersToCRC32Mutex.Unlock()
|
|
}()
|
|
|
|
// Create session
|
|
env.echo.GET("/create-api-session", func(c echo.Context) error {
|
|
createSession(c, "admin", true, uint32(12345))
|
|
return c.String(http.StatusOK, "ok")
|
|
})
|
|
|
|
called := false
|
|
env.echo.GET("/api-protected", APIAuth(func(c echo.Context) error {
|
|
called = true
|
|
return c.String(http.StatusOK, "passed")
|
|
}))
|
|
|
|
req1, rec1 := jsonRequest(http.MethodGet, "/create-api-session", nil)
|
|
env.echo.ServeHTTP(rec1, req1)
|
|
|
|
cookies := rec1.Result().Cookies()
|
|
req2, rec2 := jsonRequest(http.MethodGet, "/api-protected", nil)
|
|
for _, cookie := range cookies {
|
|
req2.AddCookie(cookie)
|
|
}
|
|
env.echo.ServeHTTP(rec2, req2)
|
|
assert.True(t, called)
|
|
assert.Equal(t, http.StatusOK, rec2.Code)
|
|
}
|
|
|
|
func TestAPIAdmin_PassesThrough(t *testing.T) {
|
|
// With DisableLogin=true, isAdmin returns true, so middleware should pass through
|
|
util.DisableLogin = true
|
|
defer func() { util.DisableLogin = false }()
|
|
|
|
e := echo.New()
|
|
called := false
|
|
handler := APIAdmin(func(c echo.Context) error {
|
|
called = true
|
|
return c.String(http.StatusOK, "admin ok")
|
|
})
|
|
|
|
req, rec := jsonRequest(http.MethodGet, "/test", nil)
|
|
c := e.NewContext(req, rec)
|
|
err := handler(c)
|
|
require.NoError(t, err)
|
|
assert.True(t, called)
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
}
|
|
|
|
func TestAPIGetMe_EmptyUsername(t *testing.T) {
|
|
// Test the path where currentUser returns "" (empty session username)
|
|
origDisable := util.DisableLogin
|
|
util.DisableLogin = false
|
|
defer func() { util.DisableLogin = origDisable }()
|
|
|
|
env := setupTestEnv(t)
|
|
util.DisableLogin = false
|
|
|
|
// Create a session and then clear the username to empty string
|
|
env.echo.GET("/setup-empty-username", func(c echo.Context) error {
|
|
createSession(c, "", false, uint32(0))
|
|
return c.String(http.StatusOK, "ok")
|
|
})
|
|
|
|
env.echo.GET("/api/v1/auth/me-empty", APIGetMe(env.db))
|
|
|
|
req1, rec1 := jsonRequest(http.MethodGet, "/setup-empty-username", nil)
|
|
env.echo.ServeHTTP(rec1, req1)
|
|
require.Equal(t, http.StatusOK, rec1.Code)
|
|
|
|
cookies := rec1.Result().Cookies()
|
|
req2, rec2 := jsonRequest(http.MethodGet, "/api/v1/auth/me-empty", nil)
|
|
for _, cookie := range cookies {
|
|
req2.AddCookie(cookie)
|
|
}
|
|
env.echo.ServeHTTP(rec2, req2)
|
|
assert.Equal(t, http.StatusUnauthorized, rec2.Code)
|
|
}
|
|
|
|
func TestAPIGetMe_DBError(t *testing.T) {
|
|
// Test the error path when GetUserByName fails (user deleted from DB after session created)
|
|
origDisable := util.DisableLogin
|
|
util.DisableLogin = false
|
|
defer func() { util.DisableLogin = origDisable }()
|
|
|
|
env := setupTestEnv(t)
|
|
util.DisableLogin = false
|
|
|
|
// Create a session for a user that exists
|
|
now := time.Now().UTC()
|
|
user := model.User{Username: "doomed", Email: "doomed@test.com", Admin: false, OIDCSub: "sub-doomed", CreatedAt: now, UpdatedAt: now}
|
|
env.db.SaveUser(user)
|
|
crc := util.GetDBUserCRC32(user)
|
|
|
|
util.DBUsersToCRC32Mutex.Lock()
|
|
util.DBUsersToCRC32["doomed"] = crc
|
|
util.DBUsersToCRC32Mutex.Unlock()
|
|
defer func() {
|
|
util.DBUsersToCRC32Mutex.Lock()
|
|
delete(util.DBUsersToCRC32, "doomed")
|
|
util.DBUsersToCRC32Mutex.Unlock()
|
|
}()
|
|
|
|
env.echo.GET("/setup-doomed-session", func(c echo.Context) error {
|
|
createSession(c, "doomed", false, crc)
|
|
return c.String(http.StatusOK, "ok")
|
|
})
|
|
|
|
// Use errStore for the handler so GetUserByName always fails
|
|
env.echo.GET("/api/v1/auth/me-dberror", APIGetMe(&errStore{}))
|
|
|
|
req1, rec1 := jsonRequest(http.MethodGet, "/setup-doomed-session", nil)
|
|
env.echo.ServeHTTP(rec1, req1)
|
|
require.Equal(t, http.StatusOK, rec1.Code)
|
|
|
|
cookies := rec1.Result().Cookies()
|
|
req2, rec2 := jsonRequest(http.MethodGet, "/api/v1/auth/me-dberror", nil)
|
|
for _, cookie := range cookies {
|
|
req2.AddCookie(cookie)
|
|
}
|
|
env.echo.ServeHTTP(rec2, req2)
|
|
assert.Equal(t, http.StatusInternalServerError, rec2.Code)
|
|
}
|