feat: Add passthrough role support and related tests

- Implemented middleware to restrict access for passthrough users in management routes.
- Added unit tests for management access requirements based on user roles.
- Updated user model tests to include passthrough role validation.
- Enhanced frontend user management to support passthrough role in invite modal.
- Created end-to-end tests for passthrough user access restrictions and navigation visibility.
- Verified self-service profile management for admins and regular users.
This commit is contained in:
GitHub Actions
2026-03-03 09:14:33 +00:00
parent a3d1ae3742
commit 0fd00575a2
9 changed files with 1194 additions and 0 deletions
+3
View File
@@ -723,18 +723,21 @@ function UserDetailModal({ isOpen, onClose, user, isSelf }: UserDetailModalProps
{showPasswordSection && (
<div className="mt-3 space-y-3">
<Input
id="current-password"
label={t('users.currentPassword')}
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
<Input
id="new-password"
label={t('users.newPassword')}
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
<Input
id="confirm-password"
label={t('users.confirmPassword')}
type="password"
value={confirmPassword}
@@ -8,6 +8,7 @@ import * as proxyHostsApi from '../../api/proxyHosts'
import client from '../../api/client'
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
import { toast } from '../../utils/toast'
import { useAuth } from '../../hooks/useAuth'
// Mock APIs
vi.mock('../../api/users', () => ({
@@ -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(<UsersPage />)
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(<UsersPage />)
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(<UsersPage />)
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(<UsersPage />)
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(<UsersPage />)
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(<UsersPage />)
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(<UsersPage />)
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(<UsersPage />)
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(<UsersPage />)
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(<UsersPage />)
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()
})
})
})
})