Some minor fixes (#18)

* Some minor fixes

* fix
This commit is contained in:
Günter Grodotzki 2026-04-23 22:17:19 +02:00 committed by GitHub
parent f27863d4b9
commit 95e767a85d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1709 additions and 33 deletions

View File

@ -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 {

View File

@ -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)
}

View File

@ -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

View File

@ -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)

View File

@ -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 {

View File

@ -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)
}

View File

@ -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")
}

View File

@ -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

View File

@ -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) {

View File

@ -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)

View File

@ -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();
});
});

View File

@ -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);

View File

@ -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();
});
});
});

View File

@ -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>

View File

@ -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");
});
});

View File

@ -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);
});
});

View File

@ -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

View File

@ -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]);

View File

@ -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();
});
});
});

View File

@ -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

View File

@ -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();
});
});

View File

@ -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")