parent
f27863d4b9
commit
95e767a85d
|
|
@ -7,7 +7,7 @@ import (
|
|||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
var usernameRegexp = regexp.MustCompile(`^\w[\w\-.]*$`)
|
||||
var usernameRegexp = regexp.MustCompile(`^\w[\w\-.@]*$`)
|
||||
|
||||
// APIError is the standard error response for API v1 endpoints
|
||||
type APIError struct {
|
||||
|
|
|
|||
|
|
@ -351,3 +351,78 @@ func TestAPIAdmin_PassesThrough(t *testing.T) {
|
|||
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), false)
|
||||
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, false)
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -330,8 +330,7 @@ func APIUpdateClient(db store.IStore, cw *ConfigWriter) echo.HandlerFunc {
|
|||
}
|
||||
|
||||
client.Name = _client.Name
|
||||
// email is immutable after creation — preserve original
|
||||
client.Enabled = _client.Enabled
|
||||
// email and enabled are not editable here — use PATCH /status for enabled
|
||||
client.UseServerDNS = _client.UseServerDNS
|
||||
client.AllocatedIPs = _client.AllocatedIPs
|
||||
client.AllowedIPs = _client.AllowedIPs
|
||||
|
|
@ -657,12 +656,17 @@ func APIServerStatus(db store.IStore) echo.HandlerFunc {
|
|||
}
|
||||
allocatedIPs += ip.String()
|
||||
}
|
||||
handshakeTime := devices[i].Peers[j].LastHandshakeTime
|
||||
var handshakeRel time.Duration
|
||||
if !handshakeTime.IsZero() {
|
||||
handshakeRel = time.Since(handshakeTime)
|
||||
}
|
||||
p := PeerStatus{
|
||||
PublicKey: devices[i].Peers[j].PublicKey.String(),
|
||||
ReceivedBytes: devices[i].Peers[j].ReceiveBytes,
|
||||
TransmitBytes: devices[i].Peers[j].TransmitBytes,
|
||||
LastHandshakeTime: devices[i].Peers[j].LastHandshakeTime,
|
||||
LastHandshakeRel: time.Since(devices[i].Peers[j].LastHandshakeTime),
|
||||
LastHandshakeTime: handshakeTime,
|
||||
LastHandshakeRel: handshakeRel,
|
||||
AllocatedIP: allocatedIPs,
|
||||
}
|
||||
p.Connected = p.LastHandshakeRel < connectedThreshold
|
||||
|
|
|
|||
|
|
@ -364,6 +364,42 @@ func TestAPIUpdateClient_Success(t *testing.T) {
|
|||
assert.Equal(t, "original@test.com", client.Email)
|
||||
}
|
||||
|
||||
// Regression: editing a client must not change its enabled status.
|
||||
// The edit form does not send "enabled", so Go zero-value (false) was overwriting it.
|
||||
func TestAPIUpdateClient_PreservesEnabledStatus(t *testing.T) {
|
||||
env := setupTestEnv(t)
|
||||
|
||||
now := time.Now().UTC()
|
||||
id := xid.New().String()
|
||||
env.db.SaveClient(model.Client{
|
||||
ID: id, Name: "Stay Enabled", Email: "stay@test.com", PublicKey: "origpub",
|
||||
AllocatedIPs: []string{"10.252.1.80/32"}, AllowedIPs: []string{"0.0.0.0/0"},
|
||||
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
|
||||
Enabled: true, CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
|
||||
// update without sending "enabled" in the body (simulates the edit form)
|
||||
body := map[string]interface{}{
|
||||
"name": "Stay Enabled",
|
||||
"allocated_ips": []string{"10.252.1.80/32"},
|
||||
"allowed_ips": []string{"0.0.0.0/0"},
|
||||
"extra_allowed_ips": []string{},
|
||||
"public_key": "origpub",
|
||||
"use_server_dns": true,
|
||||
}
|
||||
req, rec := jsonRequest(http.MethodPut, "/api/v1/clients/"+id, body)
|
||||
c := env.echo.NewContext(req, rec)
|
||||
c.SetParamNames("id")
|
||||
c.SetParamValues(id)
|
||||
err := APIUpdateClient(env.db, env.cw)(c)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var client model.Client
|
||||
parseJSON(t, rec, &client)
|
||||
assert.True(t, client.Enabled, "editing a client must not disable it")
|
||||
}
|
||||
|
||||
func TestAPIUpdateClient_InvalidID(t *testing.T) {
|
||||
env := setupTestEnv(t)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@ package handler
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/gommon/log"
|
||||
|
||||
"github.com/DigitalTolk/wireguard-ui/store"
|
||||
"github.com/DigitalTolk/wireguard-ui/util"
|
||||
)
|
||||
|
||||
// APIListUsers returns all users (read-only, managed via SSO)
|
||||
|
|
@ -19,6 +22,47 @@ func APIListUsers(db store.IStore) echo.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// APIPatchUserAdmin toggles admin status for a user
|
||||
func APIPatchUserAdmin(db store.IStore) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
username := c.Param("username")
|
||||
if !usernameRegexp.MatchString(username) {
|
||||
return apiBadRequest(c, "Invalid username")
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Admin bool `json:"admin"`
|
||||
}
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return apiBadRequest(c, "Invalid request body")
|
||||
}
|
||||
|
||||
user, err := db.GetUserByName(username)
|
||||
if err != nil {
|
||||
return apiNotFound(c, "User not found")
|
||||
}
|
||||
|
||||
user.Admin = body.Admin
|
||||
user.UpdatedAt = time.Now().UTC()
|
||||
if err := db.SaveUser(user); err != nil {
|
||||
return apiInternalError(c, "Cannot update user")
|
||||
}
|
||||
|
||||
// update CRC32 cache so existing sessions reflect the change
|
||||
util.DBUsersToCRC32Mutex.Lock()
|
||||
util.DBUsersToCRC32[user.Username] = util.GetDBUserCRC32(user)
|
||||
util.DBUsersToCRC32Mutex.Unlock()
|
||||
|
||||
action := "user.demote"
|
||||
if body.Admin {
|
||||
action = "user.promote"
|
||||
}
|
||||
log.Infof("Changed admin status for %s to %v", username, body.Admin)
|
||||
auditLogEvent(c, action, "user", username, nil)
|
||||
return c.JSON(http.StatusOK, user)
|
||||
}
|
||||
}
|
||||
|
||||
// APIGetUser returns a single user by username
|
||||
func APIGetUser(db store.IStore) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package handler
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -10,6 +12,7 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/DigitalTolk/wireguard-ui/model"
|
||||
"github.com/DigitalTolk/wireguard-ui/util"
|
||||
)
|
||||
|
||||
func TestAPIListUsers(t *testing.T) {
|
||||
|
|
@ -120,3 +123,133 @@ func TestAPIGetUser_InvalidUsername(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
|
||||
// --- APIPatchUserAdmin tests ---
|
||||
|
||||
func TestAPIPatchUserAdmin_PromoteSuccess(t *testing.T) {
|
||||
env := setupTestEnv(t)
|
||||
now := time.Now().UTC()
|
||||
|
||||
env.db.SaveUser(model.User{Username: "patchuser", Email: "patch@test.com", Admin: false, OIDCSub: "sub-patch", CreatedAt: now, UpdatedAt: now})
|
||||
|
||||
body := map[string]interface{}{"admin": true}
|
||||
req, rec := jsonRequest(http.MethodPatch, "/api/v1/users/patchuser/admin", body)
|
||||
c := env.echo.NewContext(req, rec)
|
||||
c.SetParamNames("username")
|
||||
c.SetParamValues("patchuser")
|
||||
|
||||
err := APIPatchUserAdmin(env.db)(c)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var result model.User
|
||||
parseJSON(t, rec, &result)
|
||||
assert.True(t, result.Admin)
|
||||
assert.Equal(t, "patchuser", result.Username)
|
||||
|
||||
// Verify CRC32 cache was updated
|
||||
util.DBUsersToCRC32Mutex.RLock()
|
||||
_, ok := util.DBUsersToCRC32["patchuser"]
|
||||
util.DBUsersToCRC32Mutex.RUnlock()
|
||||
assert.True(t, ok, "CRC32 cache should be updated after admin change")
|
||||
}
|
||||
|
||||
func TestAPIPatchUserAdmin_DemoteSuccess(t *testing.T) {
|
||||
env := setupTestEnv(t)
|
||||
now := time.Now().UTC()
|
||||
|
||||
env.db.SaveUser(model.User{Username: "demoteuser", Email: "demote@test.com", Admin: true, OIDCSub: "sub-demote", CreatedAt: now, UpdatedAt: now})
|
||||
|
||||
body := map[string]interface{}{"admin": false}
|
||||
req, rec := jsonRequest(http.MethodPatch, "/api/v1/users/demoteuser/admin", body)
|
||||
c := env.echo.NewContext(req, rec)
|
||||
c.SetParamNames("username")
|
||||
c.SetParamValues("demoteuser")
|
||||
|
||||
err := APIPatchUserAdmin(env.db)(c)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var result model.User
|
||||
parseJSON(t, rec, &result)
|
||||
assert.False(t, result.Admin)
|
||||
}
|
||||
|
||||
func TestAPIPatchUserAdmin_InvalidUsername(t *testing.T) {
|
||||
env := setupTestEnv(t)
|
||||
|
||||
body := map[string]interface{}{"admin": true}
|
||||
req, rec := jsonRequest(http.MethodPatch, "/api/v1/users/baduser/admin", body)
|
||||
c := env.echo.NewContext(req, rec)
|
||||
c.SetParamNames("username")
|
||||
c.SetParamValues("bad user!")
|
||||
|
||||
err := APIPatchUserAdmin(env.db)(c)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
|
||||
func TestAPIPatchUserAdmin_UserNotFound(t *testing.T) {
|
||||
env := setupTestEnv(t)
|
||||
|
||||
body := map[string]interface{}{"admin": true}
|
||||
req, rec := jsonRequest(http.MethodPatch, "/api/v1/users/nonexistent/admin", body)
|
||||
c := env.echo.NewContext(req, rec)
|
||||
c.SetParamNames("username")
|
||||
c.SetParamValues("nonexistent")
|
||||
|
||||
err := APIPatchUserAdmin(env.db)(c)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code)
|
||||
}
|
||||
|
||||
func TestAPIPatchUserAdmin_InvalidBody(t *testing.T) {
|
||||
env := setupTestEnv(t)
|
||||
|
||||
// Send a request with invalid JSON body
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/users/someuser/admin", strings.NewReader("{invalid"))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := env.echo.NewContext(req, rec)
|
||||
c.SetParamNames("username")
|
||||
c.SetParamValues("someuser")
|
||||
|
||||
err := APIPatchUserAdmin(env.db)(c)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
|
||||
func TestAPIPatchUserAdmin_DBSaveError(t *testing.T) {
|
||||
db := &errStore{}
|
||||
e := echo.New()
|
||||
|
||||
body := map[string]interface{}{"admin": true}
|
||||
req, rec := jsonRequest(http.MethodPatch, "/api/v1/users/anyuser/admin", body)
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("username")
|
||||
c.SetParamValues("anyuser")
|
||||
|
||||
err := APIPatchUserAdmin(db)(c)
|
||||
require.NoError(t, err)
|
||||
// errStore.GetUserByName returns error, so we get 404
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code)
|
||||
}
|
||||
|
||||
func TestAPIPatchUserAdmin_SaveUserFails(t *testing.T) {
|
||||
// Use saveFailStore: GetUserByName succeeds, SaveUser fails
|
||||
now := time.Now().UTC()
|
||||
db := &saveFailStore{
|
||||
user: model.User{Username: "saveuser", Email: "save@test.com", Admin: false, CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
e := echo.New()
|
||||
|
||||
body := map[string]interface{}{"admin": true}
|
||||
req, rec := jsonRequest(http.MethodPatch, "/api/v1/users/saveuser/admin", body)
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("username")
|
||||
c.SetParamValues("saveuser")
|
||||
|
||||
err := APIPatchUserAdmin(db)(c)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -206,3 +206,15 @@ func TestConfigWriter_ApplyNow(t *testing.T) {
|
|||
_, err = os.Stat(filepath.Join(dir, "wg0.conf"))
|
||||
assert.NoError(t, err, "ApplyNow should write config immediately")
|
||||
}
|
||||
|
||||
// --- ConfigWriter.apply error paths with errStore ---
|
||||
|
||||
func TestConfigWriter_Apply_GetServerError(t *testing.T) {
|
||||
// errStore.GetServer() returns an error, so apply() should fail at the first db call
|
||||
tmplFS := os.DirFS("../templates")
|
||||
cw := NewConfigWriter(&errStore{}, tmplFS, 24*time.Hour)
|
||||
|
||||
err := cw.ApplyNow()
|
||||
assert.Error(t, err, "ApplyNow should fail when GetServer returns error")
|
||||
assert.Contains(t, err.Error(), "db error")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,39 @@ func (e *errStore) GetPath() string { r
|
|||
func (e *errStore) SaveHashes(model.ClientServerHashes) error { return fmt.Errorf("db error") }
|
||||
func (e *errStore) GetHashes() (model.ClientServerHashes, error) { return model.ClientServerHashes{}, fmt.Errorf("db error") }
|
||||
|
||||
// saveFailStore is a mock where reads succeed but writes fail.
|
||||
// Used to test error paths where a lookup succeeds but saving fails.
|
||||
type saveFailStore struct {
|
||||
user model.User // user returned by GetUserByName
|
||||
}
|
||||
|
||||
func (s *saveFailStore) Init() error { return nil }
|
||||
func (s *saveFailStore) GetUsers() ([]model.User, error) { return []model.User{s.user}, nil }
|
||||
func (s *saveFailStore) GetUserByName(string) (model.User, error) { return s.user, nil }
|
||||
func (s *saveFailStore) GetUserByOIDCSub(string) (model.User, error) { return s.user, nil }
|
||||
func (s *saveFailStore) SaveUser(model.User) error { return fmt.Errorf("save error") }
|
||||
func (s *saveFailStore) DeleteUser(string) error { return fmt.Errorf("save error") }
|
||||
func (s *saveFailStore) GetGlobalSettings() (model.GlobalSetting, error) { return model.GlobalSetting{}, nil }
|
||||
func (s *saveFailStore) GetServer() (model.Server, error) { return model.Server{}, nil }
|
||||
func (s *saveFailStore) GetClients(bool) ([]model.ClientData, error) { return nil, nil }
|
||||
func (s *saveFailStore) GetClientByID(string, model.QRCodeSettings) (model.ClientData, error) {
|
||||
return model.ClientData{}, nil
|
||||
}
|
||||
func (s *saveFailStore) SaveClient(model.Client) error { return fmt.Errorf("save error") }
|
||||
func (s *saveFailStore) DeleteClient(string) error { return fmt.Errorf("save error") }
|
||||
func (s *saveFailStore) SaveServerInterface(model.ServerInterface) error { return fmt.Errorf("save error") }
|
||||
func (s *saveFailStore) SaveServerKeyPair(model.ServerKeypair) error { return fmt.Errorf("save error") }
|
||||
func (s *saveFailStore) SaveGlobalSettings(model.GlobalSetting) error { return fmt.Errorf("save error") }
|
||||
func (s *saveFailStore) GetAllocatedIPs(string) ([]string, error) { return nil, nil }
|
||||
func (s *saveFailStore) GetWakeOnLanHosts() ([]model.WakeOnLanHost, error) { return nil, nil }
|
||||
func (s *saveFailStore) GetWakeOnLanHost(string) (*model.WakeOnLanHost, error) { return nil, nil }
|
||||
func (s *saveFailStore) DeleteWakeOnHostLanHost(string) error { return fmt.Errorf("save error") }
|
||||
func (s *saveFailStore) SaveWakeOnLanHost(model.WakeOnLanHost) error { return fmt.Errorf("save error") }
|
||||
func (s *saveFailStore) DeleteWakeOnHost(model.WakeOnLanHost) error { return fmt.Errorf("save error") }
|
||||
func (s *saveFailStore) GetPath() string { return "/tmp" }
|
||||
func (s *saveFailStore) SaveHashes(model.ClientServerHashes) error { return fmt.Errorf("save error") }
|
||||
func (s *saveFailStore) GetHashes() (model.ClientServerHashes, error) { return model.ClientServerHashes{}, nil }
|
||||
|
||||
type testEnv struct {
|
||||
db *sqlitedb.SqliteDB
|
||||
auditLog *audit.Logger
|
||||
|
|
|
|||
|
|
@ -539,20 +539,95 @@ func TestDoRefreshSession_EligibleForRefresh(t *testing.T) {
|
|||
require.Equal(t, http.StatusOK, rec1.Code)
|
||||
|
||||
// Step 2: Call doRefreshSession with the session cookies
|
||||
// Deduplicate cookies: keep only the LAST cookie for each name
|
||||
allCookies := rec1.Result().Cookies()
|
||||
lastCookie := make(map[string]*http.Cookie)
|
||||
for _, cookie := range allCookies {
|
||||
lastCookie[cookie.Name] = cookie
|
||||
}
|
||||
|
||||
env.echo.GET("/trigger-refresh", func(c echo.Context) error {
|
||||
doRefreshSession(c)
|
||||
return c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
cookies := rec1.Result().Cookies()
|
||||
req2, rec2 := jsonRequest(http.MethodGet, "/trigger-refresh", nil)
|
||||
for _, cookie := range cookies {
|
||||
for _, cookie := range lastCookie {
|
||||
req2.AddCookie(cookie)
|
||||
}
|
||||
env.echo.ServeHTTP(rec2, req2)
|
||||
assert.Equal(t, http.StatusOK, rec2.Code)
|
||||
}
|
||||
|
||||
func TestDoRefreshSession_SuccessfulRefresh_VerifyUpdatedAt(t *testing.T) {
|
||||
origDisable := util.DisableLogin
|
||||
util.DisableLogin = false
|
||||
origMaxDuration := util.SessionMaxDuration
|
||||
util.SessionMaxDuration = 86400 * 90 // 90 days
|
||||
defer func() {
|
||||
util.DisableLogin = origDisable
|
||||
util.SessionMaxDuration = origMaxDuration
|
||||
}()
|
||||
|
||||
env := setupTestEnv(t)
|
||||
util.DisableLogin = false
|
||||
|
||||
// Create a remember-me session, then in the same handler, manipulate it
|
||||
// to look like it was updated 2 days ago (>24h threshold).
|
||||
// IMPORTANT: we must only forward the LAST session cookie to avoid
|
||||
// gorilla/sessions reading the first (unmanipulated) one.
|
||||
env.echo.GET("/create-refresh-session", func(c echo.Context) error {
|
||||
createSession(c, "refreshme", true, uint32(99999), true)
|
||||
|
||||
sess, _ := session.Get("session", c)
|
||||
now := time.Now().UTC().Unix()
|
||||
sess.Values["created_at"] = now - 259200 // 3 days ago
|
||||
sess.Values["updated_at"] = now - 172800 // 2 days ago (well past 24h)
|
||||
sess.Save(c.Request(), c.Response())
|
||||
|
||||
return c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req1, rec1 := jsonRequest(http.MethodGet, "/create-refresh-session", nil)
|
||||
env.echo.ServeHTTP(rec1, req1)
|
||||
require.Equal(t, http.StatusOK, rec1.Code)
|
||||
|
||||
// Deduplicate cookies: keep only the LAST cookie for each name
|
||||
// (gorilla/sessions picks the first match, and createSession + sess.Save
|
||||
// both write a "session" cookie; we need the manipulated one)
|
||||
allCookies := rec1.Result().Cookies()
|
||||
lastCookie := make(map[string]*http.Cookie)
|
||||
for _, cookie := range allCookies {
|
||||
lastCookie[cookie.Name] = cookie
|
||||
}
|
||||
|
||||
// Now call the RefreshSession middleware wrapping a simple handler
|
||||
var handlerCalled bool
|
||||
env.echo.GET("/refresh-middleware-test", RefreshSession(func(c echo.Context) error {
|
||||
handlerCalled = true
|
||||
return c.String(http.StatusOK, "refreshed")
|
||||
}))
|
||||
|
||||
req2, rec2 := jsonRequest(http.MethodGet, "/refresh-middleware-test", nil)
|
||||
for _, cookie := range lastCookie {
|
||||
req2.AddCookie(cookie)
|
||||
}
|
||||
env.echo.ServeHTTP(rec2, req2)
|
||||
assert.Equal(t, http.StatusOK, rec2.Code)
|
||||
assert.True(t, handlerCalled, "next handler should be called after refresh")
|
||||
|
||||
// Verify the session was refreshed: response should contain a session_token cookie
|
||||
refreshCookies := rec2.Result().Cookies()
|
||||
foundRefresh := false
|
||||
for _, cookie := range refreshCookies {
|
||||
if cookie.Name == "session_token" {
|
||||
foundRefresh = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, foundRefresh, "doRefreshSession should emit a refreshed session_token cookie")
|
||||
}
|
||||
|
||||
// --- Integration tests: createSession + isValidSession ---
|
||||
|
||||
func TestCreateAndValidateSession(t *testing.T) {
|
||||
|
|
@ -1040,6 +1115,60 @@ func TestIsAdmin_WithAdminSession(t *testing.T) {
|
|||
assert.True(t, adminResult, "Admin session should return true for isAdmin")
|
||||
}
|
||||
|
||||
// --- isValidSession: expired time bounds ---
|
||||
|
||||
func TestIsValidSession_ExpiredTimeBounds(t *testing.T) {
|
||||
origDisable := util.DisableLogin
|
||||
util.DisableLogin = false
|
||||
origMaxDuration := util.SessionMaxDuration
|
||||
util.SessionMaxDuration = 100 // 100 seconds (short, so createdAt + 100 < now easily)
|
||||
defer func() {
|
||||
util.DisableLogin = origDisable
|
||||
util.SessionMaxDuration = origMaxDuration
|
||||
}()
|
||||
|
||||
env := setupTestEnv(t)
|
||||
util.DisableLogin = false
|
||||
|
||||
// Create a session with timestamps that will fail time bounds
|
||||
env.echo.GET("/create-expired-session", func(c echo.Context) error {
|
||||
createSession(c, "timeuser", true, uint32(77777), true)
|
||||
|
||||
// Manipulate: created 200s ago (past max duration of 100s)
|
||||
sess, _ := session.Get("session", c)
|
||||
now := time.Now().UTC().Unix()
|
||||
sess.Values["created_at"] = now - 200 // past max duration
|
||||
sess.Values["updated_at"] = now - 50 // recent enough that expiration > now
|
||||
sess.Save(c.Request(), c.Response())
|
||||
|
||||
return c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req1, rec1 := jsonRequest(http.MethodGet, "/create-expired-session", nil)
|
||||
env.echo.ServeHTTP(rec1, req1)
|
||||
require.Equal(t, http.StatusOK, rec1.Code)
|
||||
|
||||
// Deduplicate cookies (keep last for each name)
|
||||
allCookies := rec1.Result().Cookies()
|
||||
lastCookie := make(map[string]*http.Cookie)
|
||||
for _, c := range allCookies {
|
||||
lastCookie[c.Name] = c
|
||||
}
|
||||
|
||||
var valid bool
|
||||
env.echo.GET("/validate-expired-bounds", func(c echo.Context) error {
|
||||
valid = isValidSession(c)
|
||||
return c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req2, rec2 := jsonRequest(http.MethodGet, "/validate-expired-bounds", nil)
|
||||
for _, cookie := range lastCookie {
|
||||
req2.AddCookie(cookie)
|
||||
}
|
||||
env.echo.ServeHTTP(rec2, req2)
|
||||
assert.False(t, valid, "Session should be invalid when time bounds are exceeded")
|
||||
}
|
||||
|
||||
// --- isValidSession: user not in CRC32 map ---
|
||||
|
||||
func TestIsValidSession_UserRemovedFromDB(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ func RegisterAPIv1(g *echo.Group, db store.IStore, mailer emailer.Emailer, cw *h
|
|||
users := g.Group("/users", handler.APIAuth, handler.APIAdmin)
|
||||
users.GET("", handler.APIListUsers(db))
|
||||
users.GET("/:username", handler.APIGetUser(db))
|
||||
users.PATCH("/:username/admin", handler.APIPatchUserAdmin(db), handler.ContentTypeJson)
|
||||
|
||||
// Wake-on-LAN (admin only)
|
||||
wolGroup := g.Group("/wol-hosts", handler.APIAuth, handler.APIAdmin)
|
||||
|
|
|
|||
|
|
@ -57,4 +57,169 @@ describe("AboutPage", () => {
|
|||
expect(screen.getByText("DigitalTolk/wireguard-ui")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows latest release info when available", async () => {
|
||||
cleanup = mockFetch({
|
||||
"/auth/info": {
|
||||
base_path: "",
|
||||
app_version: "v1.0.0",
|
||||
git_commit: "abc123",
|
||||
client_defaults: {},
|
||||
},
|
||||
"api.github.com/repos/DigitalTolk/wireguard-ui/releases/latest": {
|
||||
tag_name: "v1.1.0",
|
||||
published_at: "2026-04-20T00:00:00Z",
|
||||
},
|
||||
"api.github.com/repos/DigitalTolk/wireguard-ui/contributors": [
|
||||
{ login: "user1", avatar_url: "https://example.com/avatar1.png", html_url: "https://github.com/user1", contributions: 10 },
|
||||
],
|
||||
});
|
||||
renderWithProviders(<AboutPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("v1.1.0")).toBeInTheDocument();
|
||||
});
|
||||
// Should also show the "Update available" badge since v1.0.0 !== v1.1.0
|
||||
expect(screen.getByText("Update available")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows contributor avatars when loaded", async () => {
|
||||
cleanup = mockFetch({
|
||||
"/auth/info": {
|
||||
base_path: "",
|
||||
app_version: "v1.0.0",
|
||||
git_commit: "abc123",
|
||||
client_defaults: {},
|
||||
},
|
||||
"api.github.com/repos/DigitalTolk/wireguard-ui/contributors": [
|
||||
{ login: "user1", avatar_url: "https://example.com/avatar1.png", html_url: "https://github.com/user1", contributions: 10 },
|
||||
{ login: "user2", avatar_url: "https://example.com/avatar2.png", html_url: "https://github.com/user2", contributions: 5 },
|
||||
],
|
||||
});
|
||||
renderWithProviders(<AboutPage />);
|
||||
await waitFor(() => {
|
||||
const img1 = screen.getByAltText("user1");
|
||||
expect(img1).toBeInTheDocument();
|
||||
expect(img1).toHaveAttribute("src", "https://example.com/avatar1.png");
|
||||
const img2 = screen.getByAltText("user2");
|
||||
expect(img2).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show Update available badge when version matches release", async () => {
|
||||
cleanup = mockFetch({
|
||||
"/auth/info": {
|
||||
base_path: "",
|
||||
app_version: "v1.0.0",
|
||||
git_commit: "abc123",
|
||||
client_defaults: {},
|
||||
},
|
||||
"api.github.com/repos/DigitalTolk/wireguard-ui/releases/latest": {
|
||||
tag_name: "v1.0.0",
|
||||
published_at: "2026-04-20T00:00:00Z",
|
||||
},
|
||||
});
|
||||
renderWithProviders(<AboutPage />);
|
||||
await waitFor(() => {
|
||||
// Both Current Version and Latest Release show v1.0.0
|
||||
const inputs = screen.getAllByDisplayValue("v1.0.0");
|
||||
expect(inputs.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
expect(screen.queryByText("Update available")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show Update available badge when version is development", async () => {
|
||||
cleanup = mockFetch({
|
||||
"/auth/info": {
|
||||
base_path: "",
|
||||
app_version: "development",
|
||||
git_commit: "abc123",
|
||||
client_defaults: {},
|
||||
},
|
||||
"api.github.com/repos/DigitalTolk/wireguard-ui/releases/latest": {
|
||||
tag_name: "v1.1.0",
|
||||
published_at: "2026-04-20T00:00:00Z",
|
||||
},
|
||||
});
|
||||
renderWithProviders(<AboutPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("development")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Update available")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows N/A for published_at when not available in release", async () => {
|
||||
cleanup = mockFetch({
|
||||
"/auth/info": {
|
||||
base_path: "",
|
||||
app_version: "v1.0.0",
|
||||
git_commit: "abc123",
|
||||
client_defaults: {},
|
||||
},
|
||||
"api.github.com/repos/DigitalTolk/wireguard-ui/releases/latest": {
|
||||
tag_name: "v1.1.0",
|
||||
published_at: null,
|
||||
},
|
||||
});
|
||||
renderWithProviders(<AboutPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("v1.1.0")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("N/A")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows skeleton when release data is not yet loaded and no contributors", async () => {
|
||||
// When fetch for GitHub returns non-ok, latestRelease should be null
|
||||
// and contributors should be empty, showing skeleton
|
||||
cleanup = mockFetch({
|
||||
"/auth/info": {
|
||||
base_path: "",
|
||||
app_version: "v1.0.0",
|
||||
git_commit: "abc123",
|
||||
client_defaults: {},
|
||||
},
|
||||
// No GitHub responses - they'll 404, which triggers null/[] return
|
||||
});
|
||||
renderWithProviders(<AboutPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("v1.0.0")).toBeInTheDocument();
|
||||
});
|
||||
// Should show "Latest Release" label with skeleton, not input
|
||||
expect(screen.getByText("Latest Release")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows copyright with project link", async () => {
|
||||
cleanup = mockFetch({
|
||||
"/auth/info": {
|
||||
base_path: "",
|
||||
app_version: "v1.0.0",
|
||||
git_commit: "abc123",
|
||||
client_defaults: {},
|
||||
},
|
||||
});
|
||||
renderWithProviders(<AboutPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/All rights reserved/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("handles version match with v prefix on tag but not on app_version", async () => {
|
||||
cleanup = mockFetch({
|
||||
"/auth/info": {
|
||||
base_path: "",
|
||||
app_version: "1.0.0",
|
||||
git_commit: "abc123",
|
||||
client_defaults: {},
|
||||
},
|
||||
"api.github.com/repos/DigitalTolk/wireguard-ui/releases/latest": {
|
||||
tag_name: "v1.0.0",
|
||||
published_at: "2026-04-20T00:00:00Z",
|
||||
},
|
||||
});
|
||||
renderWithProviders(<AboutPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("1.0.0")).toBeInTheDocument();
|
||||
});
|
||||
// tag_name "v1.0.0" should match version "1.0.0" via the `v${version}` check
|
||||
expect(screen.queryByText("Update available")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -395,6 +395,23 @@ describe("AuditPage interactions", () => {
|
|||
window.history.pushState({}, "", "/");
|
||||
});
|
||||
|
||||
it("types in search and clicks search button to apply", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch(mockResponses);
|
||||
renderWithProviders(<AuditPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("Name, email, or ID...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const searchInput = screen.getByPlaceholderText("Name, email, or ID...");
|
||||
await user.type(searchInput, "findme");
|
||||
|
||||
// Click the search button (not Enter key) to apply the filter
|
||||
const searchBtns = screen.getAllByLabelText("Search");
|
||||
await user.click(searchBtns[0]);
|
||||
});
|
||||
|
||||
it("clears a filter by setting it to empty value", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch(mockResponses);
|
||||
|
|
|
|||
|
|
@ -1261,14 +1261,73 @@ describe("ClientsPage interactions", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("handles email send error", async () => {
|
||||
const user = userEvent.setup();
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/clients/c1/email") && init?.method === "POST") {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: { code: "INTERNAL", message: "Email send failed" } }),
|
||||
text: async () => "error",
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
}
|
||||
if (url.includes("/auth/me")) {
|
||||
return {
|
||||
ok: true, status: 200,
|
||||
json: async () => adminMe,
|
||||
text: async () => JSON.stringify(adminMe),
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
}
|
||||
if (url.includes("/clients")) {
|
||||
return {
|
||||
ok: true, status: 200,
|
||||
json: async () => [sampleClient],
|
||||
text: async () => JSON.stringify([sampleClient]),
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
}
|
||||
if (url.includes("/subnet-ranges")) {
|
||||
return {
|
||||
ok: true, status: 200,
|
||||
json: async () => [],
|
||||
text: async () => "[]",
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
});
|
||||
cleanup = () => { globalThis.fetch = originalFetch; };
|
||||
|
||||
renderWithProviders(<ClientsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Email config to Test Client")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByLabelText("Email config to Test Client"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Send Config via Email")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Send"));
|
||||
|
||||
// Wait for the POST to be called
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/clients/c1/email"),
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows invalid email format for non-required email in edit validation", async () => {
|
||||
const user = userEvent.setup();
|
||||
// Create a client with an invalid email set on the Client object
|
||||
// Since the email field in edit is disabled, we can test the validateClientForm
|
||||
// with emailRequired=true and a bad email by looking at the edit validation
|
||||
// Actually, edit validation passes the editDialog.email which is disabled
|
||||
// The edit form validation checks editDialog?.email so can't directly test invalid
|
||||
// Let's instead test the validation path in create form
|
||||
cleanup = mockFetch({
|
||||
"/auth/me": adminMe,
|
||||
"/clients": [],
|
||||
|
|
@ -1294,4 +1353,70 @@ describe("ClientsPage interactions", () => {
|
|||
expect(screen.getByText("Invalid email format")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error for invalid allocated IPs CIDR", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({
|
||||
"/auth/me": adminMe,
|
||||
"/clients": [],
|
||||
"/suggest-client-ips": ["10.0.0.2/32"],
|
||||
"/subnet-ranges": [],
|
||||
});
|
||||
renderWithProviders(<ClientsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Client")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("New Client"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("e.g. John's Laptop")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(screen.getByPlaceholderText("e.g. John's Laptop"), "Test");
|
||||
await user.type(screen.getByPlaceholderText("john@example.com"), "a@b.com");
|
||||
|
||||
// Set invalid allocated IPs
|
||||
const allocInput = screen.getByLabelText("Allocated IPs");
|
||||
await user.clear(allocInput);
|
||||
await user.type(allocInput, "not-a-cidr");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Each IP must be valid CIDR (e.g. 10.0.0.2/32)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error for invalid allowed IPs CIDR", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({
|
||||
"/auth/me": adminMe,
|
||||
"/clients": [],
|
||||
"/suggest-client-ips": ["10.0.0.2/32"],
|
||||
"/subnet-ranges": [],
|
||||
});
|
||||
renderWithProviders(<ClientsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Client")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("New Client"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("e.g. John's Laptop")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(screen.getByPlaceholderText("e.g. John's Laptop"), "Test");
|
||||
await user.type(screen.getByPlaceholderText("john@example.com"), "a@b.com");
|
||||
|
||||
// Set invalid allowed IPs
|
||||
const allowedInput = screen.getByLabelText("Allowed IPs");
|
||||
await user.clear(allowedInput);
|
||||
await user.type(allowedInput, "invalid");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Each IP must be valid CIDR (e.g. 0.0.0.0/0)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -181,6 +181,7 @@ export function ClientsPage() {
|
|||
const [qrDialog, setQrDialog] = useState<ClientData | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newClient, setNewClient] = useState({ ...emptyCreateForm });
|
||||
const [createTouched, setCreateTouched] = useState(false);
|
||||
const [subnetRange, setSubnetRange] = useState("");
|
||||
|
||||
const [editDialog, setEditDialog] = useState<Client | null>(null);
|
||||
|
|
@ -276,6 +277,7 @@ export function ClientsPage() {
|
|||
|
||||
const handleOpenCreate = () => {
|
||||
setNewClient({ ...emptyCreateForm });
|
||||
setCreateTouched(false);
|
||||
setSubnetRange("");
|
||||
setShowCreate(true);
|
||||
};
|
||||
|
|
@ -568,7 +570,7 @@ export function ClientsPage() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>New Client</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-5 py-4 sm:grid-cols-2">
|
||||
<div className="grid gap-5 py-4 sm:grid-cols-2" onChange={() => setCreateTouched(true)}>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="new-name">Name</Label>
|
||||
<Input
|
||||
|
|
@ -579,7 +581,7 @@ export function ClientsPage() {
|
|||
setNewClient((p) => ({ ...p, name: e.target.value }))
|
||||
}
|
||||
/>
|
||||
{createErrors.name && (
|
||||
{createTouched && createErrors.name && (
|
||||
<p className="text-destructive">{createErrors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -594,7 +596,7 @@ export function ClientsPage() {
|
|||
setNewClient((p) => ({ ...p, email: e.target.value }))
|
||||
}
|
||||
/>
|
||||
{createErrors.email && (
|
||||
{createTouched && createErrors.email && (
|
||||
<p className="text-destructive">{createErrors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -632,7 +634,7 @@ export function ClientsPage() {
|
|||
}))
|
||||
}
|
||||
/>
|
||||
{createErrors.allocated_ips && (
|
||||
{createTouched && createErrors.allocated_ips && (
|
||||
<p className="text-destructive">{createErrors.allocated_ips}</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -649,7 +651,7 @@ export function ClientsPage() {
|
|||
}))
|
||||
}
|
||||
/>
|
||||
{createErrors.allowed_ips && (
|
||||
{createTouched && createErrors.allowed_ips && (
|
||||
<p className="text-destructive">{createErrors.allowed_ips}</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -666,7 +668,7 @@ export function ClientsPage() {
|
|||
}))
|
||||
}
|
||||
/>
|
||||
{createErrors.extra_allowed_ips && (
|
||||
{createTouched && createErrors.extra_allowed_ips && (
|
||||
<p className="text-destructive">{createErrors.extra_allowed_ips}</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -217,4 +217,151 @@ describe("ServerPage interactions", () => {
|
|||
|
||||
expect(postUpInput).toHaveValue("echo hello");
|
||||
});
|
||||
|
||||
it("edits Pre-Down Script field", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({ "/server": serverData });
|
||||
renderWithProviders(<ServerPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Pre-Down Script")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const preDownInput = screen.getByPlaceholderText("Optional pre-down script");
|
||||
await user.type(preDownInput, "echo predown");
|
||||
|
||||
expect(preDownInput).toHaveValue("echo predown");
|
||||
});
|
||||
|
||||
it("edits Post-Down Script field", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({ "/server": serverData });
|
||||
renderWithProviders(<ServerPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Post-Down Script")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const postDownInput = screen.getByPlaceholderText("iptables -D FORWARD ...");
|
||||
await user.type(postDownInput, "echo postdown");
|
||||
|
||||
expect(postDownInput).toHaveValue("echo postdown");
|
||||
});
|
||||
|
||||
it("handles save interface error", async () => {
|
||||
const user = userEvent.setup();
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/server/interface") && init?.method === "PUT") {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: { code: "INTERNAL", message: "Save failed" } }),
|
||||
text: async () => "error",
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
}
|
||||
if (url.includes("/server")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => serverData,
|
||||
text: async () => JSON.stringify(serverData),
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
});
|
||||
cleanup = () => { globalThis.fetch = originalFetch; };
|
||||
|
||||
renderWithProviders(<ServerPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Save")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/server/interface"),
|
||||
expect.objectContaining({ method: "PUT" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("handles regenerate keypair error", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/server/keypair") && init?.method === "POST") {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: { code: "INTERNAL", message: "Keypair regen failed" } }),
|
||||
text: async () => "error",
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
}
|
||||
if (url.includes("/server")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => serverData,
|
||||
text: async () => JSON.stringify(serverData),
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
});
|
||||
cleanup = () => { globalThis.fetch = originalFetch; };
|
||||
|
||||
renderWithProviders(<ServerPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Regenerate")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Regenerate"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/server/keypair"),
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("edits listen port", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({ "/server": serverData });
|
||||
renderWithProviders(<ServerPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("51820")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const portInput = screen.getByLabelText("Listen port");
|
||||
await user.clear(portInput);
|
||||
await user.type(portInput, "51821");
|
||||
expect(portInput).toHaveValue(51821);
|
||||
});
|
||||
|
||||
it("edits addresses field", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({ "/server": serverData });
|
||||
renderWithProviders(<ServerPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("10.0.0.1/24")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const addrInput = screen.getByLabelText("Server addresses");
|
||||
await user.clear(addrInput);
|
||||
await user.type(addrInput, "10.0.0.2/24");
|
||||
expect(addrInput).toHaveValue("10.0.0.2/24");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders, mockFetch } from "@/test/test-utils";
|
||||
|
|
@ -123,4 +123,299 @@ describe("SettingsPage interactions", () => {
|
|||
await user.clear(configInput);
|
||||
await user.type(configInput, "/etc/wireguard/wg1.conf");
|
||||
});
|
||||
|
||||
it("shows validation error for config file path not starting with /", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({ "/settings": defaultSettings });
|
||||
renderWithProviders(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("/etc/wireguard/wg0.conf")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const configInput = screen.getByDisplayValue("/etc/wireguard/wg0.conf");
|
||||
await user.clear(configInput);
|
||||
await user.type(configInput, "relative/path/wg0.conf");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Config file path must be an absolute path (start with /)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("successfully saves settings and resets form state", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({
|
||||
"/settings": defaultSettings,
|
||||
});
|
||||
renderWithProviders(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("vpn.example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Modify a field so we know state has been dirtied
|
||||
const endpointInput = screen.getByDisplayValue("vpn.example.com");
|
||||
await user.clear(endpointInput);
|
||||
await user.type(endpointInput, "new.vpn.com");
|
||||
|
||||
// Click save
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
// Verify the PUT was called
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/settings"),
|
||||
expect.objectContaining({ method: "PUT" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error when save settings fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/settings")) {
|
||||
// Return settings on GET, error on PUT
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => defaultSettings,
|
||||
text: async () => JSON.stringify(defaultSettings),
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
});
|
||||
cleanup = () => { globalThis.fetch = originalFetch; };
|
||||
|
||||
renderWithProviders(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("vpn.example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Now switch to error mode for the PUT
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/settings") && init?.method === "PUT") {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: { code: "INTERNAL", message: "Save failed" } }),
|
||||
text: async () => "error",
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
}
|
||||
if (url.includes("/settings")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => defaultSettings,
|
||||
text: async () => JSON.stringify(defaultSettings),
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
// Verify the PUT was called
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/settings"),
|
||||
expect.objectContaining({ method: "PUT" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error for empty endpoint", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({ "/settings": defaultSettings });
|
||||
renderWithProviders(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("vpn.example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const endpointInput = screen.getByDisplayValue("vpn.example.com");
|
||||
await user.clear(endpointInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Endpoint address is required")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error for empty DNS", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({ "/settings": defaultSettings });
|
||||
renderWithProviders(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("1.1.1.1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const dnsInput = screen.getByDisplayValue("1.1.1.1");
|
||||
await user.clear(dnsInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("At least one DNS server is required")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error for invalid DNS IP", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({ "/settings": defaultSettings });
|
||||
renderWithProviders(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("1.1.1.1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const dnsInput = screen.getByDisplayValue("1.1.1.1");
|
||||
await user.clear(dnsInput);
|
||||
await user.type(dnsInput, "not-an-ip");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Each DNS server must be a valid IP address")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error for invalid MTU", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({ "/settings": defaultSettings });
|
||||
renderWithProviders(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("1450")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const mtuInput = screen.getByDisplayValue("1450");
|
||||
await user.clear(mtuInput);
|
||||
await user.type(mtuInput, "500");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("MTU must be 0 (to omit) or between 1280 and 9000")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error for empty MTU", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({ "/settings": defaultSettings });
|
||||
renderWithProviders(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("1450")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const mtuInput = screen.getByDisplayValue("1450");
|
||||
await user.clear(mtuInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("MTU is required")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error for invalid keepalive", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({ "/settings": defaultSettings });
|
||||
renderWithProviders(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("15")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const kaInput = screen.getByDisplayValue("15");
|
||||
await user.clear(kaInput);
|
||||
await user.type(kaInput, "99999");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Persistent keepalive must be between 0 and 65535")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error for invalid firewall mark", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({ "/settings": defaultSettings });
|
||||
renderWithProviders(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("0xca6c")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const fwInput = screen.getByDisplayValue("0xca6c");
|
||||
await user.clear(fwInput);
|
||||
await user.type(fwInput, "not-a-number");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Must be a hex (0x...) or decimal number")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error for empty config file path", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({ "/settings": defaultSettings });
|
||||
renderWithProviders(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("/etc/wireguard/wg0.conf")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const configInput = screen.getByDisplayValue("/etc/wireguard/wg0.conf");
|
||||
await user.clear(configInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Config file path is required")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("disables save button when form is invalid", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({ "/settings": defaultSettings });
|
||||
renderWithProviders(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("vpn.example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Clear endpoint to make form invalid
|
||||
const endpointInput = screen.getByDisplayValue("vpn.example.com");
|
||||
await user.clear(endpointInput);
|
||||
|
||||
await waitFor(() => {
|
||||
const saveBtn = screen.getByText("Save Settings").closest("button");
|
||||
expect(saveBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("modifies routing table field", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({ "/settings": defaultSettings });
|
||||
renderWithProviders(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("auto")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const tblInput = screen.getByDisplayValue("auto");
|
||||
await user.clear(tblInput);
|
||||
await user.type(tblInput, "100");
|
||||
expect(tblInput).toHaveValue("100");
|
||||
});
|
||||
|
||||
it("modifies persistent keepalive field", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({ "/settings": defaultSettings });
|
||||
renderWithProviders(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("15")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const kaInput = screen.getByDisplayValue("15");
|
||||
await user.clear(kaInput);
|
||||
await user.type(kaInput, "25");
|
||||
expect(kaInput).toHaveValue(25);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ describe("StatusPage", () => {
|
|||
|
||||
it("toggles sort direction when clicking same column header", async () => {
|
||||
const user = userEvent.setup();
|
||||
const peerA = { ...connectedPeer, name: "Alpha", public_key: "pka1234567890123" };
|
||||
const peerA = { ...disconnectedPeer, name: "Alpha", public_key: "pka1234567890123" };
|
||||
const peerB = { ...disconnectedPeer, name: "Bravo", public_key: "pkb1234567890123" };
|
||||
cleanup = mockFetch({
|
||||
"/status": [{ name: "wg0", peers: [peerA, peerB] }],
|
||||
|
|
@ -240,7 +240,9 @@ describe("StatusPage", () => {
|
|||
expect(screen.getByText("Alpha")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click Name header to toggle to desc
|
||||
// First click on Name switches to name asc (Alpha before Bravo)
|
||||
await user.click(screen.getByText("Name"));
|
||||
// Second click toggles to name desc (Bravo before Alpha)
|
||||
await user.click(screen.getByText("Name"));
|
||||
|
||||
// Now Bravo should come before Alpha
|
||||
|
|
|
|||
|
|
@ -79,8 +79,8 @@ export function StatusPage() {
|
|||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const [sortKey, setSortKey] = useState<SortKey>("name");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
const [sortKey, setSortKey] = useState<SortKey>("connected");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
||||
|
||||
const toggleSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
|
|
@ -99,7 +99,12 @@ export function StatusPage() {
|
|||
const va = getSortValue(a, sortKey);
|
||||
const vb = getSortValue(b, sortKey);
|
||||
const cmp = va < vb ? -1 : va > vb ? 1 : 0;
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
const primary = sortDir === "asc" ? cmp : -cmp;
|
||||
if (primary !== 0 || sortKey === "name") return primary;
|
||||
// secondary sort by name when primary values are equal
|
||||
const na = (a.name || "").toLowerCase();
|
||||
const nb = (b.name || "").toLowerCase();
|
||||
return na < nb ? -1 : na > nb ? 1 : 0;
|
||||
}),
|
||||
}));
|
||||
}, [devices, sortKey, sortDir]);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders, mockFetch } from "@/test/test-utils";
|
||||
import { UsersPage } from "./UsersPage";
|
||||
|
||||
|
|
@ -18,19 +19,22 @@ describe("UsersPage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("shows empty state", async () => {
|
||||
it("shows empty state with correct colSpan message", async () => {
|
||||
cleanup = mockFetch({ "/users": [] });
|
||||
renderWithProviders(<UsersPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No users have logged in yet")).toBeInTheDocument();
|
||||
const cell = screen.getByText("No users have logged in yet");
|
||||
expect(cell).toBeInTheDocument();
|
||||
// Verify colSpan is 5 on the empty state cell
|
||||
expect(cell.closest("td")).toHaveAttribute("colspan", "5");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders user list", async () => {
|
||||
cleanup = mockFetch({
|
||||
"/users": [
|
||||
{ username: "admin", email: "admin@company.com", display_name: "Admin User", updated_at: "2026-04-22T12:00:00Z" },
|
||||
{ username: "jdoe", email: "jdoe@company.com", display_name: "Jane Doe", updated_at: "2026-04-21T10:00:00Z" },
|
||||
{ username: "admin", email: "admin@company.com", display_name: "Admin User", admin: true, updated_at: "2026-04-22T12:00:00Z" },
|
||||
{ username: "jdoe", email: "jdoe@company.com", display_name: "Jane Doe", admin: false, updated_at: "2026-04-21T10:00:00Z" },
|
||||
],
|
||||
});
|
||||
renderWithProviders(<UsersPage />);
|
||||
|
|
@ -49,4 +53,150 @@ describe("UsersPage", () => {
|
|||
expect(screen.getByText(/managed through your SSO provider/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Admin badge for admin users and User badge for non-admin users", async () => {
|
||||
cleanup = mockFetch({
|
||||
"/users": [
|
||||
{ username: "admin", email: "admin@company.com", display_name: "Admin User", admin: true, updated_at: "2026-04-22T12:00:00Z" },
|
||||
{ username: "jdoe", email: "jdoe@company.com", display_name: "Jane Doe", admin: false, updated_at: "2026-04-21T10:00:00Z" },
|
||||
],
|
||||
});
|
||||
renderWithProviders(<UsersPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Admin")).toBeInTheDocument();
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders admin toggle switch for each user", async () => {
|
||||
cleanup = mockFetch({
|
||||
"/users": [
|
||||
{ username: "admin", email: "admin@company.com", display_name: "Admin User", admin: true, updated_at: "2026-04-22T12:00:00Z" },
|
||||
{ username: "jdoe", email: "jdoe@company.com", display_name: "Jane Doe", admin: false, updated_at: "2026-04-21T10:00:00Z" },
|
||||
],
|
||||
});
|
||||
renderWithProviders(<UsersPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Toggle admin for admin")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Toggle admin for jdoe")).toBeInTheDocument();
|
||||
});
|
||||
// Admin switch should be checked, non-admin should not
|
||||
expect(screen.getByLabelText("Toggle admin for admin")).toBeChecked();
|
||||
expect(screen.getByLabelText("Toggle admin for jdoe")).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("triggers toggleAdmin mutation when switch is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({
|
||||
"/users": [
|
||||
{ username: "jdoe", email: "jdoe@company.com", display_name: "Jane Doe", admin: false, updated_at: "2026-04-21T10:00:00Z" },
|
||||
],
|
||||
"/users/jdoe/admin": {},
|
||||
});
|
||||
renderWithProviders(<UsersPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Toggle admin for jdoe")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const toggle = screen.getByLabelText("Toggle admin for jdoe");
|
||||
await user.click(toggle);
|
||||
|
||||
// Verify the fetch was called with PATCH to the admin endpoint
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/users/jdoe/admin"),
|
||||
expect.objectContaining({ method: "PATCH" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows toast error when toggleAdmin mutation fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
// Set up fetch to return an error for the admin toggle
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/users") && !url.includes("/admin")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => [
|
||||
{ username: "jdoe", email: "jdoe@company.com", display_name: "Jane Doe", admin: false, updated_at: "2026-04-21T10:00:00Z" },
|
||||
],
|
||||
text: async () => "[]",
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
}
|
||||
if (url.includes("/admin")) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: { code: "INTERNAL", message: "Toggle failed" } }),
|
||||
text: async () => "error",
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
});
|
||||
cleanup = () => { globalThis.fetch = originalFetch; };
|
||||
|
||||
renderWithProviders(<UsersPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Toggle admin for jdoe")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByLabelText("Toggle admin for jdoe"));
|
||||
|
||||
// Verify the PATCH call was attempted
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/users/jdoe/admin"),
|
||||
expect.objectContaining({ method: "PATCH" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows dash for missing display_name and email", async () => {
|
||||
cleanup = mockFetch({
|
||||
"/users": [
|
||||
{ username: "noinfo", email: "", display_name: "", admin: false, updated_at: "" },
|
||||
],
|
||||
});
|
||||
renderWithProviders(<UsersPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("noinfo")).toBeInTheDocument();
|
||||
});
|
||||
// display_name and email should show "-"
|
||||
const dashes = screen.getAllByText("-");
|
||||
expect(dashes.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("shows dash when updated_at is missing", async () => {
|
||||
cleanup = mockFetch({
|
||||
"/users": [
|
||||
{ username: "newuser", email: "new@co.com", display_name: "New User", admin: false, updated_at: "" },
|
||||
],
|
||||
});
|
||||
renderWithProviders(<UsersPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New User")).toBeInTheDocument();
|
||||
});
|
||||
// updated_at empty means "-" is shown in the Last Login column
|
||||
const dashes = screen.getAllByText("-");
|
||||
expect(dashes.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("shows table headers including Role column", async () => {
|
||||
cleanup = mockFetch({ "/users": [] });
|
||||
renderWithProviders(<UsersPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Username")).toBeInTheDocument();
|
||||
expect(screen.getByText("Display Name")).toBeInTheDocument();
|
||||
expect(screen.getByText("Email")).toBeInTheDocument();
|
||||
expect(screen.getByText("Role")).toBeInTheDocument();
|
||||
expect(screen.getByText("Last Login")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiGet } from "@/lib/api-client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiGet, apiPatch } from "@/lib/api-client";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -10,14 +12,23 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
import type { User } from "@/lib/types";
|
||||
|
||||
export function UsersPage() {
|
||||
const qc = useQueryClient();
|
||||
const { data: users, isLoading } = useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: () => apiGet<User[]>("/users"),
|
||||
});
|
||||
|
||||
const toggleAdmin = useMutation({
|
||||
mutationFn: ({ username, admin }: { username: string; admin: boolean }) =>
|
||||
apiPatch(`/users/${username}/admin`, { admin }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
if (isLoading) return <Skeleton className="h-64 w-full" />;
|
||||
|
||||
return (
|
||||
|
|
@ -35,6 +46,7 @@ export function UsersPage() {
|
|||
<TableHead>Username</TableHead>
|
||||
<TableHead>Display Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Last Login</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
@ -46,6 +58,20 @@ export function UsersPage() {
|
|||
</TableCell>
|
||||
<TableCell>{user.display_name || "-"}</TableCell>
|
||||
<TableCell>{user.email || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={user.admin}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAdmin.mutate({ username: user.username, admin: checked })
|
||||
}
|
||||
aria-label={`Toggle admin for ${user.username}`}
|
||||
/>
|
||||
<Badge variant={user.admin ? "default" : "secondary"}>
|
||||
{user.admin ? "Admin" : "User"}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.updated_at
|
||||
? new Date(user.updated_at).toLocaleString()
|
||||
|
|
@ -56,7 +82,7 @@ export function UsersPage() {
|
|||
{(!users || users.length === 0) && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
colSpan={5}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
No users have logged in yet
|
||||
|
|
|
|||
|
|
@ -139,4 +139,199 @@ describe("WolPage interactions", () => {
|
|||
expect(screen.getByText("Server2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("handles wake host error", async () => {
|
||||
const user = userEvent.setup();
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/wake") && init?.method === "POST") {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: { code: "INTERNAL", message: "Wake failed" } }),
|
||||
text: async () => "error",
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
}
|
||||
if (url.includes("/wol-hosts")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => [host],
|
||||
text: async () => JSON.stringify([host]),
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
});
|
||||
cleanup = () => { globalThis.fetch = originalFetch; };
|
||||
|
||||
renderWithProviders(<WolPage />);
|
||||
await waitFor(() => expect(screen.getByText("Server1")).toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByLabelText("Wake Server1"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/wake"),
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("handles delete host error", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/wol-hosts/") && init?.method === "DELETE") {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: { code: "INTERNAL", message: "Delete failed" } }),
|
||||
text: async () => "error",
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
}
|
||||
if (url.includes("/wol-hosts")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => [host],
|
||||
text: async () => JSON.stringify([host]),
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
});
|
||||
cleanup = () => { globalThis.fetch = originalFetch; };
|
||||
|
||||
renderWithProviders(<WolPage />);
|
||||
await waitFor(() => expect(screen.getByText("Server1")).toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByLabelText("Delete Server1"));
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/wol-hosts/"),
|
||||
expect.objectContaining({ method: "DELETE" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("handles create host error", async () => {
|
||||
const user = userEvent.setup();
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/wol-hosts") && init?.method === "POST") {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: { code: "INTERNAL", message: "Create failed" } }),
|
||||
text: async () => "error",
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
}
|
||||
if (url.includes("/wol-hosts")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => [],
|
||||
text: async () => "[]",
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
});
|
||||
cleanup = () => { globalThis.fetch = originalFetch; };
|
||||
|
||||
renderWithProviders(<WolPage />);
|
||||
await waitFor(() => expect(screen.getByText("New Host")).toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByText("New Host"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("e.g. File Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(screen.getByPlaceholderText("e.g. File Server"), "Test Server");
|
||||
await user.type(screen.getByPlaceholderText("AA:BB:CC:DD:EE:FF"), "11:22:33:44:55:66");
|
||||
|
||||
await user.click(screen.getByText("Create"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/wol-hosts"),
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error for empty name in create dialog", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({ "/wol-hosts": [] });
|
||||
renderWithProviders(<WolPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("New Host")).toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByText("New Host"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("AA:BB:CC:DD:EE:FF")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Type only MAC, leave name empty
|
||||
await user.type(screen.getByPlaceholderText("AA:BB:CC:DD:EE:FF"), "11:22:33:44:55:66");
|
||||
|
||||
// The create button should be disabled because name is missing
|
||||
const createBtn = screen.getByText("Create").closest("button");
|
||||
expect(createBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows validation error for invalid MAC format in create dialog", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({ "/wol-hosts": [] });
|
||||
renderWithProviders(<WolPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("New Host")).toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByText("New Host"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("e.g. File Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(screen.getByPlaceholderText("e.g. File Server"), "Test");
|
||||
await user.type(screen.getByPlaceholderText("AA:BB:CC:DD:EE:FF"), "invalid-mac");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Invalid MAC format/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation errors for empty name and MAC in create dialog", async () => {
|
||||
const user = userEvent.setup();
|
||||
cleanup = mockFetch({ "/wol-hosts": [] });
|
||||
renderWithProviders(<WolPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("New Host")).toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByText("New Host"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("e.g. File Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Both fields empty - create should be disabled
|
||||
const createBtn = screen.getByText("Create").closest("button");
|
||||
expect(createBtn).toBeDisabled();
|
||||
|
||||
// Verify the errors exist
|
||||
expect(screen.getByText("Name is required")).toBeInTheDocument();
|
||||
expect(screen.getByText("MAC address is required")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1936,6 +1936,86 @@ func TestMigrate_DerivesPublicKeys_OnReopen(t *testing.T) {
|
|||
assert.Equal(t, key2.PublicKey().String(), c2Data.Client.PublicKey, "public key must be derived on reopen")
|
||||
}
|
||||
|
||||
func TestReadJSONFile_UnreadableFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "unreadable.json")
|
||||
|
||||
// Create a file, then make it unreadable
|
||||
require.NoError(t, os.WriteFile(path, []byte(`{"key":"value"}`), 0644))
|
||||
require.NoError(t, os.Chmod(path, 0000))
|
||||
|
||||
// Ensure we restore permissions for cleanup
|
||||
defer os.Chmod(path, 0644)
|
||||
|
||||
var result map[string]string
|
||||
err := readJSONFile(path, &result)
|
||||
assert.Error(t, err, "readJSONFile should fail with unreadable file")
|
||||
}
|
||||
|
||||
func TestReadJSONFile_NonexistentFile(t *testing.T) {
|
||||
var result map[string]string
|
||||
err := readJSONFile("/nonexistent/path/file.json", &result)
|
||||
assert.Error(t, err, "readJSONFile should fail with nonexistent file")
|
||||
}
|
||||
|
||||
func TestReadJSONFile_InvalidJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "invalid.json")
|
||||
require.NoError(t, os.WriteFile(path, []byte("{invalid json"), 0644))
|
||||
|
||||
var result map[string]string
|
||||
err := readJSONFile(path, &result)
|
||||
assert.Error(t, err, "readJSONFile should fail with invalid JSON")
|
||||
}
|
||||
|
||||
func TestReadJSONFile_ValidJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "valid.json")
|
||||
require.NoError(t, os.WriteFile(path, []byte(`{"key":"value"}`), 0644))
|
||||
|
||||
var result map[string]string
|
||||
err := readJSONFile(path, &result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "value", result["key"])
|
||||
}
|
||||
|
||||
// TestInit_SkipsCreationWhenDataExists calls Init on a DB that already has data
|
||||
// to exercise the count > 0 branches in Init (skip creation of server interface,
|
||||
// keypair, global settings, and hashes).
|
||||
func TestInit_SkipsCreationWhenDataExists(t *testing.T) {
|
||||
os.Setenv("WGUI_ENDPOINT_ADDRESS", "10.0.0.1")
|
||||
defer os.Unsetenv("WGUI_ENDPOINT_ADDRESS")
|
||||
|
||||
db := newTestDB(t)
|
||||
// First Init creates defaults
|
||||
require.NoError(t, db.Init())
|
||||
|
||||
// Get original values
|
||||
origServer, err := db.GetServer()
|
||||
require.NoError(t, err)
|
||||
origPubKey := origServer.KeyPair.PublicKey
|
||||
|
||||
origGS, err := db.GetGlobalSettings()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Modify the global settings to something custom
|
||||
origGS.EndpointAddress = "custom.endpoint.com"
|
||||
require.NoError(t, db.SaveGlobalSettings(origGS))
|
||||
|
||||
// Second Init should NOT overwrite existing data
|
||||
require.NoError(t, db.Init())
|
||||
|
||||
// Verify server keypair was NOT regenerated
|
||||
server2, err := db.GetServer()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, origPubKey, server2.KeyPair.PublicKey, "keypair should not change on second Init")
|
||||
|
||||
// Verify global settings were NOT overwritten
|
||||
gs2, err := db.GetGlobalSettings()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "custom.endpoint.com", gs2.EndpointAddress, "global settings should not be overwritten on second Init")
|
||||
}
|
||||
|
||||
func TestMigrateFromJSON_SkipsNonJSON(t *testing.T) {
|
||||
os.Setenv("WGUI_ENDPOINT_ADDRESS", "10.0.0.1")
|
||||
defer os.Unsetenv("WGUI_ENDPOINT_ADDRESS")
|
||||
|
|
|
|||
Loading…
Reference in New Issue