From 95e767a85dd23caed2eefcfdadb61c1f6e742629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=BCnter=20Grodotzki?= Date: Thu, 23 Apr 2026 22:17:19 +0200 Subject: [PATCH] Some minor fixes (#18) * Some minor fixes * fix --- handler/api_errors.go | 2 +- handler/api_v1_auth_test.go | 75 +++++ handler/api_v1_clients.go | 12 +- handler/api_v1_clients_test.go | 36 +++ handler/api_v1_users.go | 44 +++ handler/api_v1_users_test.go | 133 +++++++++ handler/config_writer_test.go | 12 + handler/handler_test_helpers_test.go | 33 +++ handler/session_test.go | 133 ++++++++- router/api.go | 1 + src/pages/AboutPage.test.tsx | 165 +++++++++++ src/pages/AuditPage.interaction.test.tsx | 17 ++ src/pages/ClientsPage.interaction.test.tsx | 137 ++++++++- src/pages/ClientsPage.tsx | 14 +- src/pages/ServerPage.interaction.test.tsx | 147 ++++++++++ src/pages/SettingsPage.interaction.test.tsx | 297 +++++++++++++++++++- src/pages/StatusPage.test.tsx | 6 +- src/pages/StatusPage.tsx | 11 +- src/pages/UsersPage.test.tsx | 160 ++++++++++- src/pages/UsersPage.tsx | 32 ++- src/pages/WolPage.interaction.test.tsx | 195 +++++++++++++ store/sqlitedb/sqlitedb_test.go | 80 ++++++ 22 files changed, 1709 insertions(+), 33 deletions(-) diff --git a/handler/api_errors.go b/handler/api_errors.go index 7a57482..399b6c0 100644 --- a/handler/api_errors.go +++ b/handler/api_errors.go @@ -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 { diff --git a/handler/api_v1_auth_test.go b/handler/api_v1_auth_test.go index 7cadca9..038bbc1 100644 --- a/handler/api_v1_auth_test.go +++ b/handler/api_v1_auth_test.go @@ -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) +} diff --git a/handler/api_v1_clients.go b/handler/api_v1_clients.go index e26ffa3..7fa757a 100644 --- a/handler/api_v1_clients.go +++ b/handler/api_v1_clients.go @@ -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 diff --git a/handler/api_v1_clients_test.go b/handler/api_v1_clients_test.go index 6657bed..dfd853a 100644 --- a/handler/api_v1_clients_test.go +++ b/handler/api_v1_clients_test.go @@ -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) diff --git a/handler/api_v1_users.go b/handler/api_v1_users.go index 510a077..6e837eb 100644 --- a/handler/api_v1_users.go +++ b/handler/api_v1_users.go @@ -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 { diff --git a/handler/api_v1_users_test.go b/handler/api_v1_users_test.go index 7aced09..954e91f 100644 --- a/handler/api_v1_users_test.go +++ b/handler/api_v1_users_test.go @@ -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) +} diff --git a/handler/config_writer_test.go b/handler/config_writer_test.go index 93fc793..cfffa73 100644 --- a/handler/config_writer_test.go +++ b/handler/config_writer_test.go @@ -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") +} diff --git a/handler/handler_test_helpers_test.go b/handler/handler_test_helpers_test.go index dfb7483..a6c33bc 100644 --- a/handler/handler_test_helpers_test.go +++ b/handler/handler_test_helpers_test.go @@ -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 diff --git a/handler/session_test.go b/handler/session_test.go index 4e7669b..506662f 100644 --- a/handler/session_test.go +++ b/handler/session_test.go @@ -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) { diff --git a/router/api.go b/router/api.go index dee05b4..490e9cc 100644 --- a/router/api.go +++ b/router/api.go @@ -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) diff --git a/src/pages/AboutPage.test.tsx b/src/pages/AboutPage.test.tsx index 004eb57..d608798 100644 --- a/src/pages/AboutPage.test.tsx +++ b/src/pages/AboutPage.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); }); diff --git a/src/pages/AuditPage.interaction.test.tsx b/src/pages/AuditPage.interaction.test.tsx index 0b0296e..f8f5ea8 100644 --- a/src/pages/AuditPage.interaction.test.tsx +++ b/src/pages/AuditPage.interaction.test.tsx @@ -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(); + + 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); diff --git a/src/pages/ClientsPage.interaction.test.tsx b/src/pages/ClientsPage.interaction.test.tsx index 06e4d73..1a60fb3 100644 --- a/src/pages/ClientsPage.interaction.test.tsx +++ b/src/pages/ClientsPage.interaction.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + }); + }); }); diff --git a/src/pages/ClientsPage.tsx b/src/pages/ClientsPage.tsx index ab4abee..475eaf3 100644 --- a/src/pages/ClientsPage.tsx +++ b/src/pages/ClientsPage.tsx @@ -181,6 +181,7 @@ export function ClientsPage() { const [qrDialog, setQrDialog] = useState(null); const [showCreate, setShowCreate] = useState(false); const [newClient, setNewClient] = useState({ ...emptyCreateForm }); + const [createTouched, setCreateTouched] = useState(false); const [subnetRange, setSubnetRange] = useState(""); const [editDialog, setEditDialog] = useState(null); @@ -276,6 +277,7 @@ export function ClientsPage() { const handleOpenCreate = () => { setNewClient({ ...emptyCreateForm }); + setCreateTouched(false); setSubnetRange(""); setShowCreate(true); }; @@ -568,7 +570,7 @@ export function ClientsPage() { New Client -
+
setCreateTouched(true)}>
({ ...p, name: e.target.value })) } /> - {createErrors.name && ( + {createTouched && createErrors.name && (

{createErrors.name}

)}
@@ -594,7 +596,7 @@ export function ClientsPage() { setNewClient((p) => ({ ...p, email: e.target.value })) } /> - {createErrors.email && ( + {createTouched && createErrors.email && (

{createErrors.email}

)}
@@ -632,7 +634,7 @@ export function ClientsPage() { })) } /> - {createErrors.allocated_ips && ( + {createTouched && createErrors.allocated_ips && (

{createErrors.allocated_ips}

)}
@@ -649,7 +651,7 @@ export function ClientsPage() { })) } /> - {createErrors.allowed_ips && ( + {createTouched && createErrors.allowed_ips && (

{createErrors.allowed_ips}

)} @@ -666,7 +668,7 @@ export function ClientsPage() { })) } /> - {createErrors.extra_allowed_ips && ( + {createTouched && createErrors.extra_allowed_ips && (

{createErrors.extra_allowed_ips}

)} diff --git a/src/pages/ServerPage.interaction.test.tsx b/src/pages/ServerPage.interaction.test.tsx index e3e3c5c..351f7dd 100644 --- a/src/pages/ServerPage.interaction.test.tsx +++ b/src/pages/ServerPage.interaction.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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"); + }); }); diff --git a/src/pages/SettingsPage.interaction.test.tsx b/src/pages/SettingsPage.interaction.test.tsx index a429633..163ba9a 100644 --- a/src/pages/SettingsPage.interaction.test.tsx +++ b/src/pages/SettingsPage.interaction.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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); + }); }); diff --git a/src/pages/StatusPage.test.tsx b/src/pages/StatusPage.test.tsx index 9d28484..f339c22 100644 --- a/src/pages/StatusPage.test.tsx +++ b/src/pages/StatusPage.test.tsx @@ -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 diff --git a/src/pages/StatusPage.tsx b/src/pages/StatusPage.tsx index baecf55..7208787 100644 --- a/src/pages/StatusPage.tsx +++ b/src/pages/StatusPage.tsx @@ -79,8 +79,8 @@ export function StatusPage() { refetchInterval: 5000, }); - const [sortKey, setSortKey] = useState("name"); - const [sortDir, setSortDir] = useState("asc"); + const [sortKey, setSortKey] = useState("connected"); + const [sortDir, setSortDir] = useState("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]); diff --git a/src/pages/UsersPage.test.tsx b/src/pages/UsersPage.test.tsx index 1904391..35c3c5c 100644 --- a/src/pages/UsersPage.test.tsx +++ b/src/pages/UsersPage.test.tsx @@ -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(); 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(); @@ -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(); + 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(); + 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(); + + 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(); + + 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(); + 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(); + 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(); + 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(); + }); + }); }); diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx index 1492028..d7d3c02 100644 --- a/src/pages/UsersPage.tsx +++ b/src/pages/UsersPage.tsx @@ -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("/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 ; return ( @@ -35,6 +46,7 @@ export function UsersPage() { Username Display Name Email + Role Last Login @@ -46,6 +58,20 @@ export function UsersPage() { {user.display_name || "-"} {user.email || "-"} + +
+ + toggleAdmin.mutate({ username: user.username, admin: checked }) + } + aria-label={`Toggle admin for ${user.username}`} + /> + + {user.admin ? "Admin" : "User"} + +
+
{user.updated_at ? new Date(user.updated_at).toLocaleString() @@ -56,7 +82,7 @@ export function UsersPage() { {(!users || users.length === 0) && ( No users have logged in yet diff --git a/src/pages/WolPage.interaction.test.tsx b/src/pages/WolPage.interaction.test.tsx index 2525c3f..3e13f26 100644 --- a/src/pages/WolPage.interaction.test.tsx +++ b/src/pages/WolPage.interaction.test.tsx @@ -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(); + 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(); + 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(); + 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(); + + 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(); + + 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(); + + 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(); + }); }); diff --git a/store/sqlitedb/sqlitedb_test.go b/store/sqlitedb/sqlitedb_test.go index d6d5d4f..efc29ff 100644 --- a/store/sqlitedb/sqlitedb_test.go +++ b/store/sqlitedb/sqlitedb_test.go @@ -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")