import { test, expect, type Page } from '@playwright/test' // Synthetic JWT and routed API responses. This exercises the shipped UI only; // it does not verify backend enforcement, Portal/Sender or a live end-user route. const id = 'fa58fa40-4bcb-4616-9316-cb69463b3150' const at = '2026-10-29T00:00:00Z' const read = { id: 'logs.query', toolName: 'mm_logs_query', title: 'Поиск логов', section: 'logs', version: 1, effect: 'read', risk: 'ordinary', scope: 'servers', requiredPermissions: ['mm:servers:read'], implementation: 'available', annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } } const forbidden = { ...read, id: 'users.delete', title: 'Запрещённая операция каталога', requiredPermissions: ['mm:users:admin'], effect: 'write' } async function setup(page: Page, permissions: string[]) { await page.addInitScript(permissions => { localStorage.setItem('mmapp_token', 'header.' + btoa(JSON.stringify({ sub: 'synthetic-owner', apps: ['mm'], permissions, exp: 4102444800 })) + '.signature') }, permissions) const connection = { id, name: 'Синтетический AI клиент', ownerId: 'synthetic-owner', policy: { id, version: 1, status: 'active', mode: 'observer', expiresAt: '2099-01-01T00:00:00Z', permissions: ['mm:servers:read'], scope: { serverIds: [1], operationIds: ['logs.query'], allowGlobal: false }, dangerousPolicy: 'approval', autonomousDangerousOperationIds: [], limits: { callsPerMinute: 60, concurrentReads: 4, mutationsPerHour: 10, serversPerPlan: 5, objectsPerPlan: 100 } }, createdAt: at, updatedAt: at, lastUsedAt: null, generationValid: true } const mutations: { path: string, body: Record }[] = [] let enabled = true await page.route('**/api/**', async route => { const request = route.request(), path = new URL(request.url()).pathname, method = request.method() const headers = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET,POST,PUT,DELETE,OPTIONS', 'access-control-allow-headers': 'Authorization,Content-Type' } if (method === 'OPTIONS') return route.fulfill({ status: 204, headers }) const json = (body: unknown) => route.fulfill({ headers, json: body }) if (method !== 'GET') mutations.push({ path, body: request.postDataJSON() }) if (path === '/api/auth/config') return json({ required: true }) if (path === '/api/mcp/catalog') return json({ stage: 'runtime', executionAvailable: true, operations: [read, forbidden] }) if (path === '/api/mcp/overview') return json({ connections: 1, calls: 3, errors: 1, approvals: 0, running: 0, enabled, observedAt: at }) if (path === '/api/mcp/connections') return json(method === 'POST' ? { connection, token: 'synthetic-one-time-token' } : { connections: [connection] }) if (path === `/api/mcp/connections/${id}/status`) { connection.policy.status = request.postDataJSON().status; return json({ ok: true }) } if (path === '/api/mcp/stop-automation') { enabled = false; return json({ ok: true }) } if (path === '/api/mcp/requests') return json({ items: [{ id: 'synthetic-call', tool: 'mm_logs_query', created_at: at, connection_id: id, status: connection.policy.status === 'active' ? 'completed' : 'denied', error_code: connection.policy.status === 'active' ? null : `connection_${connection.policy.status}`, detail: 'synthetic fixture outcome' }], total: 1, nextCursor: null }) if (path.endsWith('/grants')) return json({ grants: [] }) if (path === '/api/servers') return json([]) return json({ items: [], total: 0, nextCursor: null }) }) return { mutations } } test('synthetic: issue only selected server/read permission and hide one-time token', async ({ page }) => { const state = await setup(page, ['mm:mcp:admin', 'mm:servers:read']) await page.goto('/mcp?tab=connections') await page.getByRole('button', { name: 'Выдать доступ', exact: true }).click() const dialog = page.getByRole('dialog') await dialog.getByLabel('Название', { exact: true }).fill('Ограниченная диагностика') await dialog.getByRole('button', { name: 'Далее', exact: true }).click() await expect(dialog.getByRole('checkbox', { name: forbidden.title, exact: true })).toHaveCount(0) await dialog.getByLabel('ID разрешённых серверов через запятую').fill('1') await dialog.getByRole('checkbox', { name: read.title, exact: true }).check() await dialog.getByRole('button', { name: 'Далее', exact: true }).click() await dialog.getByRole('button', { name: 'Далее', exact: true }).click() await expect(dialog).toContainText('Глобальный доступ: нет') await dialog.getByRole('button', { name: 'Сохранить доступ', exact: true }).click() const token = page.getByLabel('Одноразовый токен MCP') await expect(token).toHaveValue('synthetic-one-time-token') await expect(token).toHaveAttribute('type', 'password') expect(state.mutations).toHaveLength(1) expect(state.mutations[0]).toMatchObject({ path: '/api/mcp/connections', body: { permissions: ['mm:servers:read'], scope: { serverIds: [1], operationIds: ['logs.query'], allowGlobal: false }, mode: 'observer' } }) await page.getByRole('button', { name: 'Закрыть и скрыть токен' }).click() await expect(token).toHaveCount(0) await page.reload() await expect(token).toHaveCount(0) }) test('synthetic: pause, resume, cancelled revoke, revoke, monitor denied outcome, stop automation', async ({ page }) => { const state = await setup(page, ['mm:mcp:admin', 'mm:mcp:pause', 'mm:servers:read']) await page.goto('/mcp?tab=connections') await page.getByRole('button', { name: 'Пауза', exact: true }).click() await expect(page.getByRole('button', { name: 'Включить', exact: true })).toBeEnabled() expect(state.mutations.at(-1)?.body).toEqual({ status: 'paused' }) await page.getByRole('tab', { name: 'Обращения', exact: true }).click() await expect(page.getByRole('cell', { name: 'connection_paused', exact: true })).toBeVisible() await page.getByRole('button', { name: 'Открыть', exact: true }).click() await expect(page.getByRole('dialog')).toContainText('synthetic fixture outcome') await page.keyboard.press('Escape') await page.getByRole('tab', { name: 'Подключения', exact: true }).click() await page.getByRole('button', { name: 'Включить', exact: true }).click() await expect(page.getByRole('button', { name: 'Пауза', exact: true })).toBeEnabled() page.once('dialog', dialog => dialog.dismiss()) await page.getByRole('button', { name: 'Отозвать', exact: true }).click() expect(state.mutations.filter(x => x.body.status === 'revoked')).toHaveLength(0) page.once('dialog', dialog => dialog.accept()) await page.getByRole('button', { name: 'Отозвать', exact: true }).click() await expect(page.getByRole('button', { name: 'Отозвать', exact: true })).toBeDisabled() await expect(page.getByRole('button', { name: 'Включить', exact: true })).toBeDisabled() await expect(page.getByRole('button', { name: 'Новый токен', exact: true })).toBeDisabled() await page.getByRole('tab', { name: 'Обращения', exact: true }).click() await expect(page.getByRole('cell', { name: 'connection_revoked', exact: true })).toBeVisible() await expect(page.getByRole('cell', { name: 'denied', exact: true })).toBeVisible() await page.getByRole('tab', { name: 'Обзор', exact: true }).click() page.once('dialog', dialog => dialog.accept()) await page.getByRole('button', { name: 'Остановить автоматические изменения', exact: true }).click() await expect(page.getByText('MCP выключен', { exact: true })).toBeVisible() expect(state.mutations.map(x => [x.path, x.body])).toEqual([ [`/api/mcp/connections/${id}/status`, { status: 'paused' }], [`/api/mcp/connections/${id}/status`, { status: 'active' }], [`/api/mcp/connections/${id}/status`, { status: 'revoked' }], ['/api/mcp/stop-automation', {}], ]) }) test('synthetic: read-only role cannot issue/revoke/pause or reach settings', async ({ page }) => { const state = await setup(page, ['mm:mcp:read']) await page.goto('/mcp?tab=connections') await expect(page.getByText('Синтетический AI клиент', { exact: true })).toBeVisible() await expect(page.getByRole('tab', { name: 'Настройки', exact: true })).toHaveCount(0) for (const name of ['Выдать доступ', 'Пауза', 'Отозвать', 'Новый токен', 'Выдать временный допуск']) await expect(page.getByRole('button', { name, exact: true })).toHaveCount(0) await page.getByRole('tab', { name: 'Обзор', exact: true }).click() await expect(page.getByText('MCP включён', { exact: true })).toBeVisible() await expect(page.getByRole('button', { name: 'Остановить автоматические изменения', exact: true })).toHaveCount(0) await page.goto('/mcp?tab=settings') await expect(page.getByRole('heading', { name: 'MCP', exact: true })).toBeVisible() await expect(page.getByText('Настройки MCP', { exact: true })).toHaveCount(0) expect(state.mutations).toEqual([]) }) test('synthetic: direct application URLs enforce declared route permissions', async ({ page }) => { await setup(page, ['mm:mcp:read', 'mm:servers:read']) await page.goto('/logs') await expect(page.getByRole('heading', { name: 'Логи MikroTik', exact: true })).toBeVisible() await page.goto('/mcp?tab=settings') await expect(page.getByRole('heading', { name: 'MCP', exact: true })).toBeVisible() for (const path of ['/settings', '/traffic']) { await page.goto(path) await expect(page).toHaveURL(/\/access-denied$/) await expect(page.getByRole('heading', { name: 'Нет доступа', exact: true })).toBeVisible() } })