125 lines
9.8 KiB
TypeScript
125 lines
9.8 KiB
TypeScript
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<string, unknown> }[] = []
|
|
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()
|
|
}
|
|
})
|