Spaces:
Sleeping
Sleeping
| // FE-ADMIN-MCP-001 to FE-ADMIN-MCP-016 | |
| import { render, screen, waitFor } from '../../../tests/helpers/render'; | |
| import userEvent from '@testing-library/user-event'; | |
| import { http, HttpResponse } from 'msw'; | |
| import { server } from '../../../tests/helpers/msw/server'; | |
| import { resetAllStores } from '../../../tests/helpers/store'; | |
| import { ToastContainer } from '../shared/Toast'; | |
| import AdminMcpTokensPanel from './AdminMcpTokensPanel'; | |
| const TOKEN_1 = { | |
| id: 1, | |
| name: 'CI Token', | |
| token_prefix: 'trek_abc', | |
| created_at: '2025-01-15T00:00:00Z', | |
| last_used_at: null, | |
| user_id: 10, | |
| username: 'alice', | |
| }; | |
| const TOKEN_2 = { | |
| id: 2, | |
| name: 'Ops Token', | |
| token_prefix: 'trek_xyz', | |
| created_at: '2025-03-01T00:00:00Z', | |
| last_used_at: '2025-04-01T00:00:00Z', | |
| user_id: 11, | |
| username: 'bob', | |
| }; | |
| beforeEach(() => { | |
| resetAllStores(); | |
| }); | |
| afterEach(() => { | |
| server.resetHandlers(); | |
| }); | |
| describe('AdminMcpTokensPanel', () => { | |
| it('FE-ADMIN-MCP-001: loading spinner shown on mount', async () => { | |
| server.use( | |
| http.get('/api/admin/mcp-tokens', async () => { | |
| await new Promise(resolve => setTimeout(resolve, 200)); | |
| return HttpResponse.json({ tokens: [] }); | |
| }) | |
| ); | |
| render(<AdminMcpTokensPanel />); | |
| expect(document.querySelector('.animate-spin')).toBeInTheDocument(); | |
| }); | |
| it('FE-ADMIN-MCP-002: empty state rendered when no tokens', async () => { | |
| render(<AdminMcpTokensPanel />); | |
| await screen.findByText('No MCP tokens have been created yet'); | |
| }); | |
| it('FE-ADMIN-MCP-003: token list renders correctly', async () => { | |
| server.use( | |
| http.get('/api/admin/mcp-tokens', () => | |
| HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] }) | |
| ) | |
| ); | |
| render(<AdminMcpTokensPanel />); | |
| await screen.findByText('CI Token'); | |
| expect(screen.getByText('Ops Token')).toBeInTheDocument(); | |
| expect(screen.getByText('alice')).toBeInTheDocument(); | |
| expect(screen.getByText('bob')).toBeInTheDocument(); | |
| // token_prefix is rendered as `{token.token_prefix}...` — two adjacent text nodes | |
| expect(screen.getByText(/trek_abc/)).toBeInTheDocument(); | |
| expect(screen.getByText(/trek_xyz/)).toBeInTheDocument(); | |
| }); | |
| it('FE-ADMIN-MCP-004: "Never" shown when last_used_at is null', async () => { | |
| server.use( | |
| http.get('/api/admin/mcp-tokens', () => | |
| HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] }) | |
| ) | |
| ); | |
| render(<AdminMcpTokensPanel />); | |
| await screen.findByText('CI Token'); | |
| expect(screen.getByText('Never')).toBeInTheDocument(); | |
| }); | |
| it('FE-ADMIN-MCP-005: delete confirmation dialog opens', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/admin/mcp-tokens', () => | |
| HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] }) | |
| ) | |
| ); | |
| render(<AdminMcpTokensPanel />); | |
| await screen.findByText('CI Token'); | |
| const deleteButtons = screen.getAllByTitle('Delete'); | |
| await user.click(deleteButtons[0]); | |
| expect(screen.getByText('Delete Token')).toBeInTheDocument(); | |
| expect(screen.getByText('Cancel')).toBeInTheDocument(); | |
| // Dialog Delete button has visible text "Delete"; trash icon buttons have no text content | |
| expect(screen.getByText('Delete')).toBeInTheDocument(); | |
| }); | |
| it('FE-ADMIN-MCP-006: cancel closes confirmation dialog without deleting', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/admin/mcp-tokens', () => | |
| HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] }) | |
| ) | |
| ); | |
| render(<AdminMcpTokensPanel />); | |
| await screen.findByText('CI Token'); | |
| const deleteButtons = screen.getAllByTitle('Delete'); | |
| await user.click(deleteButtons[0]); | |
| expect(screen.getByText('Delete Token')).toBeInTheDocument(); | |
| await user.click(screen.getByText('Cancel')); | |
| expect(screen.queryByText('Delete Token')).not.toBeInTheDocument(); | |
| expect(screen.getByText('CI Token')).toBeInTheDocument(); | |
| expect(screen.getByText('Ops Token')).toBeInTheDocument(); | |
| }); | |
| it('FE-ADMIN-MCP-007: backdrop click closes dialog', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/admin/mcp-tokens', () => | |
| HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] }) | |
| ) | |
| ); | |
| render(<AdminMcpTokensPanel />); | |
| await screen.findByText('CI Token'); | |
| const deleteButtons = screen.getAllByTitle('Delete'); | |
| await user.click(deleteButtons[0]); | |
| expect(screen.getByText('Delete Token')).toBeInTheDocument(); | |
| const backdrop = document.querySelector('.fixed.inset-0'); | |
| expect(backdrop).toBeInTheDocument(); | |
| await user.click(backdrop!); | |
| await waitFor(() => { | |
| expect(screen.queryByText('Delete Token')).not.toBeInTheDocument(); | |
| }); | |
| }); | |
| it('FE-ADMIN-MCP-008: successful delete removes token from list', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/admin/mcp-tokens', () => | |
| HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] }) | |
| ), | |
| http.delete('/api/admin/mcp-tokens/:id', () => | |
| HttpResponse.json({ success: true }) | |
| ) | |
| ); | |
| render(<><ToastContainer /><AdminMcpTokensPanel /></>); | |
| await screen.findByText('CI Token'); | |
| const deleteButtons = screen.getAllByTitle('Delete'); | |
| await user.click(deleteButtons[0]); | |
| await user.click(screen.getByText('Delete')); | |
| await waitFor(() => { | |
| expect(screen.queryByText('Delete Token')).not.toBeInTheDocument(); | |
| }); | |
| expect(screen.queryByText('CI Token')).not.toBeInTheDocument(); | |
| expect(screen.getByText('Ops Token')).toBeInTheDocument(); | |
| await screen.findByText('Token deleted'); | |
| }); | |
| it('FE-ADMIN-MCP-009: failed delete shows error toast and keeps list unchanged', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/admin/mcp-tokens', () => | |
| HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] }) | |
| ), | |
| http.delete('/api/admin/mcp-tokens/:id', () => | |
| HttpResponse.json({ error: 'forbidden' }, { status: 403 }) | |
| ) | |
| ); | |
| render(<><ToastContainer /><AdminMcpTokensPanel /></>); | |
| await screen.findByText('CI Token'); | |
| const deleteButtons = screen.getAllByTitle('Delete'); | |
| await user.click(deleteButtons[0]); | |
| await user.click(screen.getByText('Delete')); | |
| await screen.findByText('Failed to delete token'); | |
| expect(screen.getByText('CI Token')).toBeInTheDocument(); | |
| }); | |
| it('FE-ADMIN-MCP-010: load failure shows error toast', async () => { | |
| server.use( | |
| http.get('/api/admin/mcp-tokens', () => | |
| HttpResponse.json({ error: 'server error' }, { status: 500 }) | |
| ) | |
| ); | |
| render(<><ToastContainer /><AdminMcpTokensPanel /></>); | |
| await screen.findByText('Failed to load tokens'); | |
| }); | |
| it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => { | |
| server.use( | |
| http.get('/api/admin/oauth-sessions', async () => { | |
| await new Promise(resolve => setTimeout(resolve, 200)); | |
| return HttpResponse.json({ sessions: [] }); | |
| }) | |
| ); | |
| render(<AdminMcpTokensPanel />); | |
| expect(document.querySelector('.animate-spin')).toBeInTheDocument(); | |
| }); | |
| it('FE-ADMIN-MCP-012: OAuth sessions empty state rendered when no sessions', async () => { | |
| server.use( | |
| http.get('/api/admin/oauth-sessions', () => | |
| HttpResponse.json({ sessions: [] }) | |
| ) | |
| ); | |
| render(<AdminMcpTokensPanel />); | |
| await screen.findByText('No active OAuth sessions'); | |
| }); | |
| it('FE-ADMIN-MCP-013: OAuth sessions list renders with scopes', async () => { | |
| server.use( | |
| http.get('/api/admin/oauth-sessions', () => | |
| HttpResponse.json({ | |
| sessions: [ | |
| { | |
| id: 1, | |
| client_name: 'Claude Desktop', | |
| username: 'alice', | |
| scopes: ['trips:read', 'budget:read'], | |
| created_at: '2025-01-01T00:00:00Z', | |
| }, | |
| ], | |
| }) | |
| ) | |
| ); | |
| render(<AdminMcpTokensPanel />); | |
| await screen.findByText('Claude Desktop'); | |
| expect(screen.getByText('alice')).toBeInTheDocument(); | |
| expect(screen.getByText('trips:read')).toBeInTheDocument(); | |
| }); | |
| it('FE-ADMIN-MCP-014: scope expand/collapse toggle shows hidden scopes', async () => { | |
| const user = userEvent.setup(); | |
| // 7 scopes — more than SCOPES_PREVIEW=6, so "+1 more" button appears | |
| const scopes = ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read']; | |
| server.use( | |
| http.get('/api/admin/oauth-sessions', () => | |
| HttpResponse.json({ | |
| sessions: [ | |
| { id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' }, | |
| ], | |
| }) | |
| ) | |
| ); | |
| render(<AdminMcpTokensPanel />); | |
| await screen.findByText('App'); | |
| // "+1 more" button should appear | |
| const moreBtn = await screen.findByText(/\+1 more/); | |
| expect(moreBtn).toBeInTheDocument(); | |
| await user.click(moreBtn); | |
| // After expand, "show less" appears | |
| expect(await screen.findByText('show less')).toBeInTheDocument(); | |
| }); | |
| it('FE-ADMIN-MCP-015: revoke session confirmation and successful revoke', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/admin/oauth-sessions', () => | |
| HttpResponse.json({ | |
| sessions: [ | |
| { id: 5, client_name: 'Revoke Me', username: 'carol', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' }, | |
| ], | |
| }) | |
| ), | |
| http.delete('/api/admin/oauth-sessions/5', () => | |
| HttpResponse.json({ success: true }) | |
| ) | |
| ); | |
| render(<><ToastContainer /><AdminMcpTokensPanel /></>); | |
| await screen.findByText('Revoke Me'); | |
| // Click the revoke (trash) button next to the session | |
| const deleteBtn = screen.getAllByTitle('Delete')[0]; | |
| await user.click(deleteBtn); | |
| // Confirmation modal opens | |
| expect(screen.getByText('Revoke Session')).toBeInTheDocument(); | |
| // Confirm — find the modal's Delete button (has no title, unlike the trash icon) | |
| const deleteBtns = screen.getAllByRole('button', { name: 'Delete' }); | |
| const confirmBtn = deleteBtns.find(b => !b.title); | |
| await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]); | |
| await waitFor(() => { | |
| expect(screen.queryByText('Revoke Me')).not.toBeInTheDocument(); | |
| }); | |
| }); | |
| it('FE-ADMIN-MCP-016: revoke session error shows toast', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/admin/oauth-sessions', () => | |
| HttpResponse.json({ | |
| sessions: [ | |
| { id: 6, client_name: 'Error Session', username: 'dave', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' }, | |
| ], | |
| }) | |
| ), | |
| http.delete('/api/admin/oauth-sessions/6', () => | |
| HttpResponse.json({ error: 'forbidden' }, { status: 403 }) | |
| ) | |
| ); | |
| render(<><ToastContainer /><AdminMcpTokensPanel /></>); | |
| await screen.findByText('Error Session'); | |
| const deleteBtn = screen.getAllByTitle('Delete')[0]; | |
| await user.click(deleteBtn); | |
| const deleteBtns = screen.getAllByRole('button', { name: 'Delete' }); | |
| const confirmBtn = deleteBtns.find(b => !b.title); | |
| await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]); | |
| await screen.findByText('Failed to revoke session'); | |
| }); | |
| }); | |