forked from mirror/crumbs-original
test: add E2E tests for MCP endpoint and Settings page
- MCP tests: initialize, create/get note, search, auth rejection - Settings tests: create key, revoke key, sidebar nav, subpage nav Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
Fred Cabassut
co-authored by
Claude Opus 4.6
parent
8e1f8f0413
commit
d338da1d1a
@@ -0,0 +1,190 @@
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { setupAndLogin } from './helpers/fixtures.js';
|
||||
|
||||
/**
|
||||
* MCP E2E tests exercise the full MCP protocol flow:
|
||||
* 1. Create an API key via the settings API (authenticated via session)
|
||||
* 2. Use the key to authenticate MCP requests
|
||||
* 3. Test tool invocations via JSON-RPC over Streamable HTTP
|
||||
*/
|
||||
|
||||
const test = base.extend<{ apiKey: string }>({
|
||||
apiKey: async ({ page }, use) => {
|
||||
await setupAndLogin(page);
|
||||
|
||||
// Create API key via settings endpoint (session-authenticated)
|
||||
const res = await page.request.post('/api/settings/api-keys', {
|
||||
data: { name: 'E2E Test Key' }
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const { key } = await res.json();
|
||||
await use(key);
|
||||
}
|
||||
});
|
||||
|
||||
async function mcpInitialize(page: Page, apiKey: string) {
|
||||
const res = await page.request.post('/api/mcp', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream'
|
||||
},
|
||||
data: {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-03-26',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'e2e-test', version: '1.0.0' }
|
||||
}
|
||||
}
|
||||
});
|
||||
const status = res.status();
|
||||
const body = await res.text();
|
||||
if (!res.ok()) {
|
||||
throw new Error(`MCP initialize failed with status ${status}: ${body.slice(0, 500)}`);
|
||||
}
|
||||
const sessionId = res.headers()['mcp-session-id'];
|
||||
expect(sessionId).toBeTruthy();
|
||||
|
||||
// Send initialized notification
|
||||
await page.request.post('/api/mcp', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
'mcp-session-id': sessionId
|
||||
},
|
||||
data: {
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/initialized'
|
||||
}
|
||||
});
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
async function mcpCallTool(
|
||||
page: Page,
|
||||
apiKey: string,
|
||||
sessionId: string,
|
||||
name: string,
|
||||
args: Record<string, unknown> = {},
|
||||
id = 2
|
||||
) {
|
||||
const res = await page.request.post('/api/mcp', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
'mcp-session-id': sessionId
|
||||
},
|
||||
data: {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method: 'tools/call',
|
||||
params: { name, arguments: args }
|
||||
}
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const text = await res.text();
|
||||
// Response may be SSE format or JSON
|
||||
if (text.startsWith('event:') || text.startsWith('data:')) {
|
||||
// Parse SSE - find the last data line with actual content
|
||||
const lines = text.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (data.result) return data.result;
|
||||
}
|
||||
}
|
||||
throw new Error('No result found in SSE response');
|
||||
}
|
||||
|
||||
return JSON.parse(text).result;
|
||||
}
|
||||
|
||||
test.describe('MCP Server', () => {
|
||||
test('Scenario: MCP client can list notes via the protocol', async ({ page, apiKey }) => {
|
||||
const sessionId = await mcpInitialize(page, apiKey);
|
||||
|
||||
// When calling list_notes tool
|
||||
const result = await mcpCallTool(page, apiKey, sessionId, 'list_notes');
|
||||
|
||||
// Then the response contains notes content
|
||||
expect(result.content).toBeDefined();
|
||||
expect(result.content[0].type).toBe('text');
|
||||
const notes = JSON.parse(result.content[0].text);
|
||||
expect(Array.isArray(notes)).toBe(true);
|
||||
});
|
||||
|
||||
test('Scenario: MCP client can create and retrieve a note', async ({ page, apiKey }) => {
|
||||
const sessionId = await mcpInitialize(page, apiKey);
|
||||
|
||||
// When creating a note via MCP
|
||||
const createResult = await mcpCallTool(page, apiKey, sessionId, 'create_note', {
|
||||
title: 'MCP Test Note',
|
||||
content: 'Created via MCP protocol'
|
||||
});
|
||||
const created = JSON.parse(createResult.content[0].text);
|
||||
expect(created.title).toBe('MCP Test Note');
|
||||
|
||||
// Then the note can be retrieved
|
||||
const getResult = await mcpCallTool(
|
||||
page,
|
||||
apiKey,
|
||||
sessionId,
|
||||
'get_note',
|
||||
{ id: created.id },
|
||||
3
|
||||
);
|
||||
const fetched = JSON.parse(getResult.content[0].text);
|
||||
expect(fetched.title).toBe('MCP Test Note');
|
||||
expect(fetched.content).toBe('Created via MCP protocol');
|
||||
});
|
||||
|
||||
test('Scenario: MCP client can search notes', async ({ page, apiKey }) => {
|
||||
const sessionId = await mcpInitialize(page, apiKey);
|
||||
|
||||
// Given a note with specific content exists
|
||||
await mcpCallTool(page, apiKey, sessionId, 'create_note', {
|
||||
title: 'Searchable Note',
|
||||
content: 'unique-search-term-xyz'
|
||||
});
|
||||
|
||||
// When searching for it
|
||||
const searchResult = await mcpCallTool(
|
||||
page,
|
||||
apiKey,
|
||||
sessionId,
|
||||
'search_notes',
|
||||
{ query: 'unique-search-term-xyz' },
|
||||
3
|
||||
);
|
||||
const results = JSON.parse(searchResult.content[0].text);
|
||||
|
||||
// Then it appears in results
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].title).toBe('Searchable Note');
|
||||
});
|
||||
|
||||
test('Scenario: MCP endpoint rejects requests without API key', async ({ page }) => {
|
||||
const res = await page.request.post('/api/mcp', {
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
|
||||
data: {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-03-26',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test', version: '1.0.0' }
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { test, expect } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Settings — API Key Management', () => {
|
||||
test('Scenario: Created API key appears in the keys list', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// When the user navigates to the MCP settings and creates an API key
|
||||
await page.goto('/settings/mcp');
|
||||
await page.getByTestId('api-key-name-input').fill('Settings Test Key');
|
||||
await page.getByTestId('create-api-key-btn').click();
|
||||
|
||||
// Then the key is shown once for copying
|
||||
await expect(page.getByTestId('created-key-display')).toBeVisible();
|
||||
const keyValue = await page.getByTestId('created-key-value').textContent();
|
||||
expect(keyValue).toMatch(/^crumbs_/);
|
||||
|
||||
// And it appears in the keys list
|
||||
const keyItem = page.getByTestId('api-key-item').filter({ hasText: 'Settings Test Key' });
|
||||
await expect(keyItem).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Revoked API key disappears from the keys list', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given an API key exists
|
||||
await page.goto('/settings/mcp');
|
||||
await page.getByTestId('api-key-name-input').fill('Revoke Me');
|
||||
await page.getByTestId('create-api-key-btn').click();
|
||||
await expect(page.getByTestId('created-key-display')).toBeVisible();
|
||||
|
||||
const keyItem = page.getByTestId('api-key-item').filter({ hasText: 'Revoke Me' });
|
||||
await expect(keyItem).toBeVisible();
|
||||
|
||||
// When the user revokes the key
|
||||
await keyItem.getByTestId('delete-api-key-btn').click();
|
||||
await keyItem.getByTestId('confirm-delete-btn').click();
|
||||
|
||||
// Then the key is removed from the list
|
||||
await expect(keyItem).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Settings page is accessible from sidebar', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given the user is on the main page
|
||||
// When the user clicks the Settings link in the sidebar
|
||||
await page.getByTestId('settings-link').click();
|
||||
|
||||
// Then the settings page is shown
|
||||
await expect(page).toHaveURL('/settings');
|
||||
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Settings subpages are navigable', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given the user is on the settings page
|
||||
await page.goto('/settings');
|
||||
|
||||
// When the user clicks the MCP Server nav link
|
||||
await page.getByRole('link', { name: 'MCP Server' }).click();
|
||||
|
||||
// Then the MCP settings page is shown
|
||||
await expect(page).toHaveURL('/settings/mcp');
|
||||
await expect(page.getByText('API Keys')).toBeVisible();
|
||||
|
||||
// When the user clicks back to Profile
|
||||
await page.getByRole('link', { name: 'Profile' }).click();
|
||||
await expect(page).toHaveURL('/settings');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user