mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
// 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 />);
|
|
expect(await screen.findByText('No MCP tokens have been created yet')).toBeInTheDocument();
|
|
});
|
|
|
|
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.getAllByLabelText('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.getAllByLabelText('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.getAllByLabelText('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.getAllByLabelText('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.getAllByLabelText('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 /></>);
|
|
expect(await screen.findByText('Failed to load tokens')).toBeInTheDocument();
|
|
});
|
|
|
|
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 />);
|
|
expect(await screen.findByText('No active OAuth sessions')).toBeInTheDocument();
|
|
});
|
|
|
|
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.getAllByLabelText('Delete')[0];
|
|
await user.click(deleteBtn);
|
|
|
|
// Confirmation modal opens
|
|
expect(screen.getByText('Revoke Session')).toBeInTheDocument();
|
|
// Confirm — find the modal's Delete button (has no aria-label, unlike the trash icon)
|
|
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
|
|
const confirmBtn = deleteBtns.find(b => !b.hasAttribute('aria-label'));
|
|
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.getAllByLabelText('Delete')[0];
|
|
await user.click(deleteBtn);
|
|
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
|
|
const confirmBtn = deleteBtns.find(b => !b.hasAttribute('aria-label'));
|
|
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
|
|
expect(await screen.findByText('Failed to revoke session')).toBeInTheDocument();
|
|
});
|
|
});
|