mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
7a22d742ab
Adds new and expanded test suites across client and server to cover the OAuth 2.1 scope system, MCP session manager, collab service, unified memories helpers, OIDC service, budget slice, and OAuth authorize page. Also extends SonarQube coverage exclusions to include bootstrapping files (migrations, scheduler, main.tsx, types.ts) that are not meaningfully testable.
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 />);
|
|
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');
|
|
});
|
|
});
|