diff --git a/backend/internal/api/handlers/user_handler_test.go b/backend/internal/api/handlers/user_handler_test.go index 9d2df2eb..ab2dee9f 100644 --- a/backend/internal/api/handlers/user_handler_test.go +++ b/backend/internal/api/handlers/user_handler_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" "github.com/gin-gonic/gin" @@ -2717,3 +2718,394 @@ func TestRedactInviteURL(t *testing.T) { }) } } + +// --- Passthrough rejection tests --- + +func setupPassthroughRouter(handler *UserHandler) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", string(models.RolePassthrough)) + c.Next() + }) + r.POST("/api-key", handler.RegenerateAPIKey) + r.GET("/profile", handler.GetProfile) + r.PUT("/profile", handler.UpdateProfile) + return r +} + +func TestUserHandler_RegenerateAPIKey_PassthroughRejected(t *testing.T) { + handler, _ := setupUserHandler(t) + r := setupPassthroughRouter(handler) + + req := httptest.NewRequest(http.MethodPost, "/api-key", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "Passthrough users cannot manage API keys") +} + +func TestUserHandler_GetProfile_PassthroughRejected(t *testing.T) { + handler, _ := setupUserHandler(t) + r := setupPassthroughRouter(handler) + + req := httptest.NewRequest(http.MethodGet, "/profile", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "Passthrough users cannot access profile") +} + +func TestUserHandler_UpdateProfile_PassthroughRejected(t *testing.T) { + handler, _ := setupUserHandler(t) + r := setupPassthroughRouter(handler) + + body, _ := json.Marshal(map[string]string{"name": "Test"}) + req := httptest.NewRequest(http.MethodPut, "/profile", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "Passthrough users cannot update profile") +} + +// --- CreateUser / InviteUser invalid role --- + +func TestUserHandler_CreateUser_InvalidRole(t *testing.T) { + handler, _ := setupUserHandler(t) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + r.POST("/users", handler.CreateUser) + + body, _ := json.Marshal(map[string]string{ + "name": "Test User", + "email": "new@example.com", + "role": "superadmin", + "password": "password123", + }) + req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Invalid role") +} + +func TestUserHandler_InviteUser_InvalidRole(t *testing.T) { + handler, _ := setupUserHandler(t) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", uint(1)) + c.Next() + }) + r.POST("/invite", handler.InviteUser) + + body, _ := json.Marshal(map[string]string{ + "email": "invite@example.com", + "role": "superadmin", + }) + req := httptest.NewRequest(http.MethodPost, "/invite", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Invalid role") +} + +// --- UpdateUser authentication/session edge cases --- + +func TestUserHandler_UpdateUser_MissingUserID(t *testing.T) { + handler, db := setupUserHandler(t) + + user := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target@example.com", Role: models.RoleUser, Enabled: true} + require.NoError(t, db.Create(&user).Error) + + gin.SetMode(gin.TestMode) + r := gin.New() + // No userID set in context + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + r.PUT("/users/:id", handler.UpdateUser) + + body, _ := json.Marshal(map[string]string{"name": "New Name"}) + req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", user.ID), bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "Authentication required") +} + +func TestUserHandler_UpdateUser_InvalidSessionType(t *testing.T) { + handler, db := setupUserHandler(t) + + user := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target2@example.com", Role: models.RoleUser, Enabled: true} + require.NoError(t, db.Create(&user).Error) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", "not-a-uint") // wrong type + c.Next() + }) + r.PUT("/users/:id", handler.UpdateUser) + + body, _ := json.Marshal(map[string]string{"name": "New Name"}) + req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", user.ID), bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "Invalid session") +} + +// --- UpdateUser role/enabled restriction for non-admin self --- + +func TestUserHandler_UpdateUser_NonAdminSelfRoleChange(t *testing.T) { + handler, db := setupUserHandler(t) + + user := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "self@example.com", Role: models.RoleUser, Enabled: true} + require.NoError(t, db.Create(&user).Error) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "user") // non-admin + c.Set("userID", user.ID) // isSelf = true + c.Next() + }) + r.PUT("/users/:id", handler.UpdateUser) + + body, _ := json.Marshal(map[string]string{"role": "admin"}) + req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", user.ID), bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "Cannot modify role or enabled status") +} + +// --- UpdateUser invalid role string --- + +func TestUserHandler_UpdateUser_InvalidRole(t *testing.T) { + handler, db := setupUserHandler(t) + + target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target3@example.com", Role: models.RoleUser, Enabled: true} + require.NoError(t, db.Create(&target).Error) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", uint(9999)) // not the target + c.Next() + }) + r.PUT("/users/:id", handler.UpdateUser) + + body, _ := json.Marshal(map[string]string{"role": "superadmin"}) + req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Invalid role") +} + +// --- UpdateUser self-demotion and self-disable --- + +func TestUserHandler_UpdateUser_SelfDemotion(t *testing.T) { + handler, db := setupUserHandler(t) + + admin := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "admin@self.example.com", Role: models.RoleAdmin, Enabled: true} + require.NoError(t, db.Create(&admin).Error) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", admin.ID) // isSelf = true + c.Next() + }) + r.PUT("/users/:id", handler.UpdateUser) + + body, _ := json.Marshal(map[string]string{"role": "user"}) + req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", admin.ID), bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "Cannot change your own role") +} + +func TestUserHandler_UpdateUser_SelfDisable(t *testing.T) { + handler, db := setupUserHandler(t) + + admin := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "admin@disable.example.com", Role: models.RoleAdmin, Enabled: true} + require.NoError(t, db.Create(&admin).Error) + + disabled := false + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", admin.ID) // isSelf = true + c.Next() + }) + r.PUT("/users/:id", handler.UpdateUser) + + body, _ := json.Marshal(map[string]interface{}{"enabled": disabled}) + req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", admin.ID), bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "Cannot disable your own account") +} + +// --- UpdateUser last-admin protection --- + +func TestUserHandler_UpdateUser_LastAdminDemotion(t *testing.T) { + handler, db := setupUserHandler(t) + + // Only one admin in the DB (the target) + target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "last-admin@example.com", Role: models.RoleAdmin, Enabled: true} + require.NoError(t, db.Create(&target).Error) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", uint(9999)) // different from target; not in DB but role injected via context + c.Next() + }) + r.PUT("/users/:id", handler.UpdateUser) + + body, _ := json.Marshal(map[string]string{"role": "user"}) + req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "Cannot demote the last admin") +} + +func TestUserHandler_UpdateUser_LastAdminDisable(t *testing.T) { + handler, db := setupUserHandler(t) + + target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "last-admin-disable@example.com", Role: models.RoleAdmin, Enabled: true} + require.NoError(t, db.Create(&target).Error) + + disabled := false + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", uint(9999)) + c.Next() + }) + r.PUT("/users/:id", handler.UpdateUser) + + body, _ := json.Marshal(map[string]interface{}{"enabled": disabled}) + req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "Cannot disable the last admin") +} + +// --- UpdateUser session invalidation --- + +func TestUserHandler_UpdateUser_WithSessionInvalidation(t *testing.T) { + db := OpenTestDB(t) + _ = db.AutoMigrate(&models.User{}, &models.Setting{}, &models.SecurityAudit{}) + + authSvc := services.NewAuthService(db, config.Config{JWTSecret: "test-secret"}) + + caller := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "caller-si@example.com", Role: models.RoleAdmin, Enabled: true} + target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target-si@example.com", Role: models.RoleUser, Enabled: true} + require.NoError(t, db.Create(&caller).Error) + require.NoError(t, db.Create(&target).Error) + + handler := NewUserHandler(db, authSvc) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", caller.ID) + c.Next() + }) + r.PUT("/users/:id", handler.UpdateUser) + + body, _ := json.Marshal(map[string]string{"role": "passthrough"}) + req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "User updated successfully") + + var updated models.User + require.NoError(t, db.First(&updated, target.ID).Error) + assert.Greater(t, updated.SessionVersion, uint(0)) +} + +func TestUserHandler_UpdateUser_SessionInvalidationError(t *testing.T) { + mainDB := OpenTestDB(t) + _ = mainDB.AutoMigrate(&models.User{}, &models.Setting{}, &models.SecurityAudit{}) + + // Use a separate empty DB so InvalidateSessions cannot find the user + authDB := OpenTestDB(t) + authSvc := services.NewAuthService(authDB, config.Config{JWTSecret: "test-secret"}) + + target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target-sie@example.com", Role: models.RoleUser, Enabled: true} + require.NoError(t, mainDB.Create(&target).Error) + + handler := NewUserHandler(mainDB, authSvc) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", uint(9999)) + c.Next() + }) + r.PUT("/users/:id", handler.UpdateUser) + + body, _ := json.Marshal(map[string]string{"role": "passthrough"}) + req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "Failed to invalidate sessions") +} diff --git a/backend/internal/api/middleware/auth_test.go b/backend/internal/api/middleware/auth_test.go index 119862a2..245d8538 100644 --- a/backend/internal/api/middleware/auth_test.go +++ b/backend/internal/api/middleware/auth_test.go @@ -427,3 +427,61 @@ func TestExtractAuthCookieToken_IgnoresNonAuthCookies(t *testing.T) { token := extractAuthCookieToken(ctx) assert.Equal(t, "", token) } + +func TestRequireManagementAccess_PassthroughBlocked(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", string(models.RolePassthrough)) + c.Next() + }) + r.Use(RequireManagementAccess()) + r.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "Pass-through users cannot access management features") +} + +func TestRequireManagementAccess_UserAllowed(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", string(models.RoleUser)) + c.Next() + }) + r.Use(RequireManagementAccess()) + r.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestRequireManagementAccess_AdminAllowed(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", string(models.RoleAdmin)) + c.Next() + }) + r.Use(RequireManagementAccess()) + r.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} diff --git a/backend/internal/api/routes/routes_test.go b/backend/internal/api/routes/routes_test.go index d5fcf600..fb32b7c6 100644 --- a/backend/internal/api/routes/routes_test.go +++ b/backend/internal/api/routes/routes_test.go @@ -10,7 +10,9 @@ import ( "testing" "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/models" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" @@ -1298,3 +1300,25 @@ func TestRegister_CreatesAccessLogFileForLogWatcher(t *testing.T) { _, statErr := os.Stat(logFilePath) assert.NoError(t, statErr) } + +func TestMigrateViewerToPassthrough(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.User{})) + + // Seed a user with the legacy "viewer" role + viewer := models.User{ + UUID: uuid.NewString(), + APIKey: uuid.NewString(), + Email: "viewer@example.com", + Role: models.UserRole("viewer"), + Enabled: true, + } + require.NoError(t, db.Create(&viewer).Error) + + migrateViewerToPassthrough(db) + + var updated models.User + require.NoError(t, db.First(&updated, viewer.ID).Error) + assert.Equal(t, models.RolePassthrough, updated.Role) +} diff --git a/backend/internal/models/user_test.go b/backend/internal/models/user_test.go index 48296d5f..1eeebf66 100644 --- a/backend/internal/models/user_test.go +++ b/backend/internal/models/user_test.go @@ -190,6 +190,31 @@ func TestPermissionMode_Constants(t *testing.T) { assert.Equal(t, PermissionMode("deny_all"), PermissionModeDenyAll) } +func TestUserRole_Constants(t *testing.T) { + assert.Equal(t, UserRole("admin"), RoleAdmin) + assert.Equal(t, UserRole("user"), RoleUser) + assert.Equal(t, UserRole("passthrough"), RolePassthrough) +} + +func TestUserRole_IsValid(t *testing.T) { + tests := []struct { + role UserRole + expected bool + }{ + {RoleAdmin, true}, + {RoleUser, true}, + {RolePassthrough, true}, + {UserRole("viewer"), false}, + {UserRole("superadmin"), false}, + {UserRole(""), false}, + } + for _, tt := range tests { + t.Run(string(tt.role), func(t *testing.T) { + assert.Equal(t, tt.expected, tt.role.IsValid()) + }) + } +} + // Helper function to create time pointers func timePtr(t time.Time) *time.Time { return &t diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index de00bad9..36d0f966 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -723,18 +723,21 @@ function UserDetailModal({ isOpen, onClose, user, isSelf }: UserDetailModalProps {showPasswordSection && (
setCurrentPassword(e.target.value)} /> setNewPassword(e.target.value)} /> ({ @@ -603,4 +604,264 @@ describe('UsersPage', () => { expect(previewQuery).toBeNull() }) }) + + describe('InviteModal role reset on close', () => { + it('resets role to user when modal is closed', async () => { + vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) + + renderWithQueryClient() + + const user = userEvent.setup() + await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument()) + + // Open invite modal + await user.click(screen.getByRole('button', { name: /Invite User/i })) + await waitFor(() => expect(screen.getByLabelText(/Role/i)).toBeInTheDocument()) + + // Change role to passthrough + await user.selectOptions(screen.getByLabelText(/Role/i), 'passthrough') + expect((screen.getByLabelText(/Role/i) as HTMLSelectElement).value).toBe('passthrough') + + // Close via Cancel button (calls handleClose which resets role) + await user.click(screen.getByRole('button', { name: /^Cancel$/i })) + + // Reopen modal — role should be reset to 'user' + await user.click(screen.getByRole('button', { name: /Invite User/i })) + await waitFor(() => expect(screen.getByLabelText(/Role/i)).toBeInTheDocument()) + expect((screen.getByLabelText(/Role/i) as HTMLSelectElement).value).toBe('user') + }) + }) + + describe('UserDetailModal', () => { + it('shows profile update error via toast', async () => { + vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) + vi.mocked(usersApi.updateUser).mockRejectedValue({ + response: { data: { error: 'Email already in use' } }, + }) + + renderWithQueryClient() + + const user = userEvent.setup() + await waitFor(() => expect(screen.getByText('Regular User')).toBeInTheDocument()) + + // Click Edit User for Regular User (second "Edit User" button in the table) + const editButtons = screen.getAllByTitle('Edit User') + await user.click(editButtons[1]) // index 1 = Regular User row + + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + // Click Save + await user.click(screen.getByRole('button', { name: /^Save$/i })) + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled() + }) + }) + + it('toggles the password change section', async () => { + vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) + vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'abc-****' } as never) + + renderWithQueryClient() + + const user = userEvent.setup() + await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument()) + + // Click Edit User in My Profile card (opens with isSelf=true) — card button is first + await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0]) + + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + // Password fields should not be visible until toggled + expect(screen.queryByLabelText(/Current Password/i)).toBeNull() + + // Click the Change Password toggle + await user.click(screen.getAllByRole('button', { name: /Change Password/i })[0]) + + // Password fields should now be visible + await waitFor(() => { + expect(screen.getByLabelText(/Current Password/i)).toBeInTheDocument() + }) + }) + + it('submits password change successfully', async () => { + vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) + vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'abc-****' } as never) + + renderWithQueryClient() + + const user = userEvent.setup() + await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument()) + + await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0]) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + // Expand password section + await user.click(screen.getAllByRole('button', { name: /Change Password/i })[0]) + await waitFor(() => expect(screen.getByLabelText(/Current Password/i)).toBeInTheDocument()) + + // Fill matching passwords + await user.type(screen.getByLabelText(/Current Password/i), 'oldpass123') + await user.type(screen.getByLabelText(/^New Password/i), 'newpass456') + await user.type(screen.getByLabelText(/Confirm Password/i), 'newpass456') + + // Submit button (second "Change Password" button — the submit one) + const changePasswordButtons = screen.getAllByRole('button', { name: /Change Password/i }) + const submitButton = changePasswordButtons[changePasswordButtons.length - 1] + await user.click(submitButton) + + await waitFor(() => { + expect(toast.success).toHaveBeenCalled() + }) + }) + + it('shows error toast on password change failure', async () => { + vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) + vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'abc-****' } as never) + vi.mocked(useAuth).mockReturnValue({ + user: { user_id: 1, role: 'admin', name: 'Admin User', email: 'admin@example.com' }, + changePassword: vi.fn().mockRejectedValue(new Error('Invalid current password')), + isAuthenticated: true, + isLoading: false, + login: vi.fn(), + logout: vi.fn(), + }) + + renderWithQueryClient() + + const user = userEvent.setup() + await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument()) + + await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0]) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + await user.click(screen.getAllByRole('button', { name: /Change Password/i })[0]) + await waitFor(() => expect(screen.getByLabelText(/Current Password/i)).toBeInTheDocument()) + + await user.type(screen.getByLabelText(/Current Password/i), 'wrongpass') + await user.type(screen.getByLabelText(/^New Password/i), 'newpass456') + await user.type(screen.getByLabelText(/Confirm Password/i), 'newpass456') + + const changePasswordButtons = screen.getAllByRole('button', { name: /Change Password/i }) + const submitButton = changePasswordButtons[changePasswordButtons.length - 1] + await user.click(submitButton) + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Invalid current password') + }) + }) + + it('regenerates API key when user confirms', async () => { + vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) + vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'old-****' } as never) + vi.mocked(usersApi.regenerateApiKey).mockResolvedValue({ api_key_masked: 'new-****' } as never) + vi.spyOn(window, 'confirm').mockReturnValue(true) + + renderWithQueryClient() + + const user = userEvent.setup() + await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument()) + + await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0]) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Regenerate API Key/i })).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: /Regenerate API Key/i })) + + await waitFor(() => { + expect(usersApi.regenerateApiKey).toHaveBeenCalled() + }) + }) + + it('updates self profile and shows profile updated toast', async () => { + vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) + vi.mocked(usersApi.updateProfile).mockResolvedValue({ message: 'ok' } as never) + vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'abc-****' } as never) + + renderWithQueryClient() + + const user = userEvent.setup() + await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument()) + + await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0]) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + const dialog = screen.getByRole('dialog') + await user.click(within(dialog).getByRole('button', { name: /^Save$/i })) + + await waitFor(() => { + expect(usersApi.updateProfile).toHaveBeenCalled() + expect(toast.success).toHaveBeenCalledWith('Profile updated successfully') + }) + }) + + it('updates non-self user profile and shows success toast', async () => { + vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) + vi.mocked(usersApi.updateUser).mockResolvedValue({ message: 'ok' } as never) + + renderWithQueryClient() + + const user = userEvent.setup() + await waitFor(() => expect(screen.getByText('Regular User')).toBeInTheDocument()) + + const editButtons = screen.getAllByTitle('Edit User') + await user.click(editButtons[1]) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + const dialog = screen.getByRole('dialog') + await user.click(within(dialog).getByRole('button', { name: /^Save$/i })) + + await waitFor(() => { + expect(usersApi.updateUser).toHaveBeenCalledWith(2, expect.objectContaining({ + email: 'user@example.com', + })) + expect(toast.success).toHaveBeenCalledWith('Profile updated successfully') + }) + }) + + it('displays masked API key text when profile query resolves', async () => { + vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) + vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'SK-****-masktest' } as never) + + renderWithQueryClient() + + const user = userEvent.setup() + await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument()) + + await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0]) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + await waitFor(() => { + expect(screen.getByText('SK-****-masktest')).toBeInTheDocument() + }) + }) + + it('shows password mismatch alert when new and confirm passwords differ', async () => { + vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) + vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: '' } as never) + + renderWithQueryClient() + + const user = userEvent.setup() + await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument()) + + await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0]) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + await user.click(screen.getAllByRole('button', { name: /Change Password/i })[0]) + await waitFor(() => expect(screen.getByLabelText(/Current Password/i)).toBeInTheDocument()) + + await user.type(screen.getByLabelText(/Current Password/i), 'current123') + await user.type(screen.getByLabelText(/^New Password/i), 'newpass1') + await user.type(screen.getByLabelText(/Confirm Password/i), 'different2') + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument() + expect(screen.getByText('Passwords do not match')).toBeInTheDocument() + }) + }) + }) }) diff --git a/tests/settings/account-settings.spec.ts b/tests/settings/account-settings.spec.ts index 9feea566..ccab35ae 100644 --- a/tests/settings/account-settings.spec.ts +++ b/tests/settings/account-settings.spec.ts @@ -29,6 +29,123 @@ test.describe('Account Settings', () => { await waitForLoadingComplete(page); }); + /** + * PR-3: Account Route Redirect (F8) + * + * Verifies that legacy account settings routes redirect to the + * consolidated Users page at /settings/users. + */ + test.describe('PR-3: Account Route Redirect (F8)', () => { + // Outer beforeEach already handles login. These tests re-navigate to the legacy + // routes to assert the React Router redirects them to /settings/users. + test('should redirect /settings/account to /settings/users', async ({ page }) => { + await page.goto('/settings/account'); + await page.waitForURL(/\/settings\/users/, { timeout: 15000 }); + await expect(page).toHaveURL(/\/settings\/users/); + }); + + test('should redirect /settings/account-management to /settings/users', async ({ page }) => { + await page.goto('/settings/account-management'); + await page.waitForURL(/\/settings\/users/, { timeout: 15000 }); + await expect(page).toHaveURL(/\/settings\/users/); + }); + }); + + /** + * PR-3: Self-Service Profile via Users Page (F10) + * + * Verifies that an admin can manage their own profile (name, email, + * password, API key) through the UserDetailModal on /settings/users. + * This replaces the deleted Account.tsx page. + */ + test.describe('PR-3: Self-Service Profile via Users Page (F10)', () => { + test.beforeEach(async ({ page }) => { + // Outer beforeEach already handles login. Navigate to the users page + // and wait for the user data to fully render before each test. + await page.goto('/settings/users'); + await waitForLoadingComplete(page); + // Wait for user data to load — the My Profile card's Edit User button + // only appears after the API returns the current user's profile. + await page.getByRole('button', { name: 'Edit User' }).first().waitFor({ + state: 'visible', + timeout: 15000, + }); + }); + + test('should open My Profile modal from the My Profile card', async ({ page }) => { + await test.step('Click Edit User in the My Profile card', async () => { + // The My Profile card button is the first "Edit User" button in the DOM + await page.getByRole('button', { name: 'Edit User' }).first().click(); + await expect(page.getByRole('dialog')).toBeVisible(); + }); + + await test.step('Verify dialog is labelled "My Profile"', async () => { + await expect( + page.getByRole('dialog').getByRole('heading', { name: 'My Profile' }) + ).toBeVisible(); + }); + + await test.step('Verify name and email fields are editable', async () => { + const dialog = page.getByRole('dialog'); + await expect(dialog.locator('input').first()).toBeVisible(); + await expect(dialog.locator('input[type="email"]')).toBeVisible(); + }); + }); + + test('should display Change Password toggle in My Profile modal (self-only)', async ({ page }) => { + await page.getByRole('button', { name: 'Edit User' }).first().click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + const dialog = page.getByRole('dialog'); + await expect(dialog.getByRole('button', { name: 'Change Password' })).toBeVisible(); + }); + + test('should reveal password fields after clicking Change Password toggle', async ({ page }) => { + await page.getByRole('button', { name: 'Edit User' }).first().click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + const dialog = page.getByRole('dialog'); + + await test.step('Password fields are hidden before toggling', async () => { + await expect(dialog.locator('#current-password')).not.toBeVisible(); + await expect(dialog.locator('#new-password')).not.toBeVisible(); + }); + + await test.step('Click Change Password to expand the section', async () => { + await dialog.getByRole('button', { name: 'Change Password' }).click(); + }); + + await test.step('Password fields are now visible', async () => { + await expect(dialog.locator('#current-password')).toBeVisible(); + await expect(dialog.locator('#new-password')).toBeVisible(); + await expect(dialog.locator('#confirm-password')).toBeVisible(); + }); + }); + + test('should display API Key section in My Profile modal (self-only)', async ({ page }) => { + await page.getByRole('button', { name: 'Edit User' }).first().click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + const dialog = page.getByRole('dialog'); + await expect(dialog.getByText('API Key', { exact: true })).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Regenerate API Key' })).toBeVisible(); + }); + + test('should have accessible structure in My Profile modal', async ({ page }) => { + await page.getByRole('button', { name: 'Edit User' }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + await test.step('Dialog has accessible heading', async () => { + await expect(dialog.getByRole('heading', { name: 'My Profile' })).toBeVisible(); + }); + + await test.step('Close button has accessible label', async () => { + await expect(dialog.getByRole('button', { name: /close/i })).toBeVisible(); + }); + }); + }); + test.describe('Profile Management', () => { /** * Test: Profile displays correctly diff --git a/tests/settings/user-lifecycle.spec.ts b/tests/settings/user-lifecycle.spec.ts index f6f866a2..548002a8 100644 --- a/tests/settings/user-lifecycle.spec.ts +++ b/tests/settings/user-lifecycle.spec.ts @@ -708,3 +708,167 @@ test.describe('Admin-User E2E Workflow', () => { }); }); }); + +/** + * PR-3: Passthrough User — Access Restriction (F4) + * + * Verifies that a passthrough-role user is redirected to the + * PassthroughLanding page when they attempt to access management routes, + * and that they cannot reach the admin Users page. + */ +test.describe('PR-3: Passthrough User Access Restriction (F4)', () => { + let adminEmail = ''; + + test.beforeEach(async ({ page, adminUser }) => { + await resetSecurityState(page); + adminEmail = adminUser.email; + await loginUser(page, adminUser); + await waitForLoadingComplete(page, { timeout: 15000 }); + }); + + test('passthrough user is redirected to PassthroughLanding when accessing management routes', async ({ page }) => { + const suffix = uniqueSuffix(); + const ptUser = { + email: `passthrough-${suffix}@test.local`, + name: `Passthrough User ${suffix}`, + password: 'PassthroughPass123!', + role: 'passthrough' as 'admin' | 'user' | 'passthrough', + }; + let ptUserId: string | number | undefined; + + await test.step('Admin creates a passthrough-role user directly', async () => { + const token = await page.evaluate(() => localStorage.getItem('charon_auth_token') || ''); + const resp = await page.request.post('/api/v1/users', { + data: ptUser, + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + expect(resp.ok()).toBe(true); + const body = await resp.json(); + ptUserId = body.id; + }); + + await test.step('Admin logs out', async () => { + await logoutUser(page); + }); + + await test.step('Passthrough user logs in', async () => { + await navigateToLogin(page); + await loginWithCredentials(page, ptUser.email, ptUser.password); + // Wait for the initial post-login navigation to settle before probing routes + await page.waitForURL(/^\/?((?!login).)*$/, { timeout: 10000 }).catch(() => {}); + }); + + await test.step('Passthrough user navigating to management route is redirected to /passthrough', async () => { + await page.goto('/settings/users', { waitUntil: 'domcontentloaded' }).catch(() => {}); + await page.waitForURL(/\/passthrough/, { timeout: 15000 }); + await expect(page).toHaveURL(/\/passthrough/); + }); + + await test.step('PassthroughLanding displays welcome heading and no-access message', async () => { + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + await expect( + page.getByText(/do not have access to the management interface/i) + ).toBeVisible(); + }); + + await test.step('PassthroughLanding shows a logout button', async () => { + await expect(page.getByRole('button', { name: /logout/i })).toBeVisible(); + }); + + await test.step('Cleanup: admin logs back in and deletes passthrough user', async () => { + // Logout passthrough user + await page.getByRole('button', { name: /logout/i }).click(); + await page.waitForURL(/login/, { timeout: 10000 }); + + // Login as admin + await loginWithCredentials(page, adminEmail, TEST_PASSWORD); + const token = await page.evaluate(() => localStorage.getItem('charon_auth_token') || ''); + if (ptUserId !== undefined) { + await page.request.delete(`/api/v1/users/${ptUserId}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + } + }); + }); +}); + +/** + * PR-3: Regular User — No Admin-Only Nav Items (F9) + * + * Verifies that a regular (non-admin) user does not see the "Users" + * navigation item, which is restricted to admins only. + */ +test.describe('PR-3: Regular User Has No Admin Navigation Items (F9)', () => { + let adminEmail = ''; + + test.beforeEach(async ({ page, adminUser }) => { + await resetSecurityState(page); + adminEmail = adminUser.email; + await loginUser(page, adminUser); + await waitForLoadingComplete(page, { timeout: 15000 }); + }); + + test('regular user does not see the Users navigation item', async ({ page }) => { + const suffix = uniqueSuffix(); + const regularUserData = { + email: `navtest-user-${suffix}@test.local`, + name: `Nav Test User ${suffix}`, + password: 'NavTestPass123!', + role: 'user' as 'admin' | 'user' | 'passthrough', + }; + let regularUserId: string | number | undefined; + + await test.step('Admin creates a regular user', async () => { + const token = await page.evaluate(() => localStorage.getItem('charon_auth_token') || ''); + const resp = await page.request.post('/api/v1/users', { + data: regularUserData, + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + expect(resp.ok()).toBe(true); + const body = await resp.json(); + regularUserId = body.id; + }); + + await test.step('Admin logs out', async () => { + await logoutUser(page); + }); + + await test.step('Regular user logs in', async () => { + await navigateToLogin(page); + await loginWithCredentials(page, regularUserData.email, regularUserData.password); + await waitForLoadingComplete(page, { timeout: 15000 }); + }); + + await test.step('Verify "Users" nav item is NOT visible for regular user', async () => { + const nav = page.getByRole('navigation').first(); + await expect(nav.getByRole('link', { name: 'Users' })).not.toBeVisible(); + }); + + await test.step('Verify other nav items ARE visible (navigation renders for regular users)', async () => { + const nav = page.getByRole('navigation').first(); + await expect(nav.getByRole('link', { name: /dashboard/i })).toBeVisible(); + }); + + await test.step('Cleanup: admin logs back in and deletes regular user', async () => { + await logoutUser(page); + await navigateToLogin(page); + await loginWithCredentials(page, adminEmail, TEST_PASSWORD); + const token = await page.evaluate(() => localStorage.getItem('charon_auth_token') || ''); + if (regularUserId !== undefined) { + await page.request.delete(`/api/v1/users/${regularUserId}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + } + }); + }); + + test('admin user sees the Users navigation item', async ({ page }) => { + await test.step('Navigate to settings to reveal Settings sub-navigation', async () => { + await page.goto('/settings/users'); + await waitForLoadingComplete(page); + }); + await test.step('Verify "Users" nav item is visible for admin in Settings nav', async () => { + await expect(page.getByRole('link', { name: 'Users', exact: true })).toBeVisible(); + }); + }); +}); diff --git a/tests/settings/user-management.spec.ts b/tests/settings/user-management.spec.ts index 7c05547d..4aea8677 100644 --- a/tests/settings/user-management.spec.ts +++ b/tests/settings/user-management.spec.ts @@ -1301,4 +1301,154 @@ test.describe('User Management', () => { }); }); }); + + /** + * PR-3: Passthrough Role in Invite Modal (F3) + * + * Verifies that the invite modal exposes all three role options: + * Admin, User, and Passthrough — and that selecting Passthrough + * surfaces the appropriate role description. + */ + test.describe('PR-3: Passthrough Role in Invite (F3)', () => { + test('should offer passthrough as a role option in the invite modal', async ({ page }) => { + await test.step('Open the Invite User modal', async () => { + const inviteButton = page.getByRole('button', { name: /invite.*user/i }); + await expect(inviteButton).toBeVisible(); + await inviteButton.click(); + await expect(page.getByRole('dialog')).toBeVisible(); + }); + + await test.step('Verify three role options are present in the role select', async () => { + const roleSelect = page.locator('#invite-user-role'); + await expect(roleSelect).toBeVisible(); + await expect(roleSelect.locator('option[value="user"]')).toHaveCount(1); + await expect(roleSelect.locator('option[value="admin"]')).toHaveCount(1); + await expect(roleSelect.locator('option[value="passthrough"]')).toHaveCount(1); + }); + + await test.step('Select passthrough and verify description is shown', async () => { + await page.locator('#invite-user-role').selectOption('passthrough'); + await expect( + page.getByText(/proxy access only|no management interface/i) + ).toBeVisible(); + }); + }); + + test('should show permission mode selector when passthrough role is selected', async ({ page }) => { + await test.step('Open invite modal and select passthrough role', async () => { + await page.getByRole('button', { name: /invite.*user/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.locator('#invite-user-role').selectOption('passthrough'); + }); + + await test.step('Verify permission mode select is visible for passthrough', async () => { + const permSelect = page.locator('#invite-permission-mode'); + await expect(permSelect).toBeVisible(); + }); + }); + }); + + /** + * PR-3: User Detail Modal — Self vs Other (F2) + * + * Verifies that UserDetailModal shows different sections depending + * on whether the admin is editing their own profile (isSelf=true) + * versus another user's profile (isSelf=false). + */ + test.describe('PR-3: User Detail Modal (F2)', () => { + test('should open My Profile modal with password and API key sections when editing self', async ({ page }) => { + await test.step('Click the Edit User button in the My Profile card (first button in DOM)', async () => { + // The My Profile card renders its button before the table rows in the DOM + await page.getByRole('button', { name: 'Edit User' }).first().click(); + await expect(page.getByRole('dialog')).toBeVisible(); + }); + + await test.step('Verify dialog title is "My Profile" (isSelf=true)', async () => { + await expect( + page.getByRole('dialog').getByRole('heading', { name: 'My Profile' }) + ).toBeVisible(); + }); + + await test.step('Verify name and email input fields are present', async () => { + const dialog = page.getByRole('dialog'); + // First input in the dialog is the name field (no type attribute) + await expect(dialog.locator('input').first()).toBeVisible(); + // Email field has type="email" + await expect(dialog.locator('input[type="email"]')).toBeVisible(); + }); + + await test.step('Verify Change Password toggle is present (self-only section)', async () => { + const dialog = page.getByRole('dialog'); + await expect(dialog.getByRole('button', { name: 'Change Password' })).toBeVisible(); + }); + + await test.step('Verify API Key section is present (self-only section)', async () => { + const dialog = page.getByRole('dialog'); + await expect(dialog.getByText('API Key', { exact: true })).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Regenerate API Key' })).toBeVisible(); + }); + }); + + test('should open Edit User modal without password/API key sections for another user', async ({ page }) => { + const suffix = Date.now(); + const otherUser = { + email: `modal-other-${suffix}@test.local`, + name: `Modal Other User ${suffix}`, + password: 'TestPass123!', + role: 'user', + }; + let otherUserId: number | string | undefined; + + await test.step('Create a second user so there is an "other" row in the table', async () => { + const token = await page.evaluate(() => localStorage.getItem('charon_auth_token') || ''); + const resp = await page.request.post('/api/v1/users', { + data: otherUser, + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + expect(resp.ok()).toBe(true); + const body = await resp.json(); + otherUserId = body.id; + await page.reload(); + await waitForLoadingComplete(page); + }); + + await test.step('Click the Edit User button in the row for the other user', async () => { + const row = page.getByRole('row').filter({ hasText: otherUser.email }); + await row.getByRole('button', { name: 'Edit User' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + }); + + await test.step('Verify dialog title is "Edit User" (isSelf=false)', async () => { + await expect( + page.getByRole('dialog').getByRole('heading', { name: 'Edit User' }) + ).toBeVisible(); + }); + + await test.step('Verify name and email fields are present', async () => { + const dialog = page.getByRole('dialog'); + await expect(dialog.locator('input').first()).toBeVisible(); + await expect(dialog.locator('input[type="email"]')).toBeVisible(); + }); + + await test.step('Verify Change Password button is NOT visible (other-user edit)', async () => { + const dialog = page.getByRole('dialog'); + await expect(dialog.getByRole('button', { name: 'Change Password' })).not.toBeVisible(); + }); + + await test.step('Verify API Key section is NOT visible (other-user edit)', async () => { + const dialog = page.getByRole('dialog'); + await expect(dialog.getByText('Regenerate API Key')).not.toBeVisible(); + }); + + await test.step('Cleanup: close modal and delete the test user', async () => { + await page.keyboard.press('Escape'); + if (otherUserId !== undefined) { + const token = await page.evaluate(() => localStorage.getItem('charon_auth_token') || ''); + await page.request.delete(`/api/v1/users/${otherUserId}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + } + }); + }); + }); });