feat: complete Epic 1 production foundation
This commit is contained in:
@@ -11,6 +11,9 @@ test('agents page shows demo cards and edit drawer opens', async ({ page }) => {
|
||||
await expect(page.locator('.agent-card-date').first()).toContainText(
|
||||
localized('Set this endpoint', 'Данный эндпоинт')
|
||||
);
|
||||
await expect(page.locator('.agent-card-date').filter({
|
||||
hasText: localized('Catalog rev', 'Ревизия каталога'),
|
||||
}).first()).toBeVisible();
|
||||
await page.getByRole('button', { name: localized('New agent', 'Новый агент') }).click();
|
||||
await expect(page.locator('.drawer-title')).toHaveText(localized('New agent', 'Новый агент'));
|
||||
await expect(page.locator('.drawer-subtitle')).toContainText(
|
||||
@@ -40,7 +43,45 @@ test('agent drawer configures on-demand tool discovery and catalog sections', as
|
||||
await expect(group.locator('input').nth(1)).toHaveValue('finance');
|
||||
await expect(page.locator('.tool-search-preview')).toBeVisible();
|
||||
await page.locator('.tool-group-chip').first().click();
|
||||
await page.locator('.tool-search-preview input').fill('currency rate');
|
||||
await page.locator('.tool-search-preview input').fill('frankfurter_latest_rate');
|
||||
await page.locator('.tool-search-preview .btn-primary-sm').click();
|
||||
await expect(page.locator('.tool-search-result').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('agent lifecycle stale conflict shows localized recovery message', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/agents');
|
||||
await expect(page.locator('.agent-card').first()).toBeVisible();
|
||||
|
||||
await page.route(/\/api\/admin\/workspaces\/[^/]+\/agents\/[^/]+\/unpublish$/, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 409,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'x-request-id': '01a00000-0000-7000-8000-000000000001',
|
||||
'x-trace-id': '01a00000000070008000000000000001',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
code: 'conflict',
|
||||
message: 'agent changed',
|
||||
context: {
|
||||
error_code: 'agent_stale_revision',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toMatch(localized('Unpublish', 'Снять'));
|
||||
await dialog.accept();
|
||||
});
|
||||
await page.getByRole('button', { name: localized('Unpublish', 'Снять с публикации') }).first().click();
|
||||
await expect(page.locator('.toast-error')).toContainText(
|
||||
localized(
|
||||
'Agent changed in another request. Reload the drawer and retry.',
|
||||
'Агент изменился в другом запросе. Перезагрузите форму и повторите действие.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { createAgent, getCurrentWorkspace, login, localized, uniqueName } = require('./helpers');
|
||||
const { browserJson, createAgent, getCurrentWorkspace, login, localized, uniqueName } = require('./helpers');
|
||||
|
||||
test('api keys page opens create key flow', async ({ page }) => {
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
await createAgent(page, workspace.id, {
|
||||
@@ -23,10 +24,21 @@ test('api keys page opens create key flow', async ({ page }) => {
|
||||
await page.locator('#btn-create-key').click();
|
||||
await expect(page.locator('#modal-create')).toHaveClass(/open/);
|
||||
await expect(page.locator('.modal-title')).toHaveText(localized('Create MCP client key', 'Создать ключ MCP-клиента'));
|
||||
await expect(page.locator('[data-scope="read"]')).toBeChecked();
|
||||
await expect(page.locator('[data-scope="write"]')).toBeChecked();
|
||||
await page.locator('#new-key-name').fill(`playwright-${Date.now()}`);
|
||||
await page.locator('#modal-confirm-btn').click();
|
||||
await expect(page.locator('#modal-reveal-body')).toContainText(localized('Copy this key now', 'Скопируйте этот ключ сейчас'));
|
||||
await expect(page.locator('#reveal-key-value')).toContainText('crk_');
|
||||
const config = page.getByTestId('onboarding-connection-config');
|
||||
await expect(config).toBeVisible();
|
||||
await config.getByRole('button', { name: localized('Copy configuration', 'Скопировать конфигурацию') }).first().click();
|
||||
await expect(page.locator('#reveal-key-value')).toHaveText('');
|
||||
await expect(config).toBeHidden();
|
||||
await expect(page.locator('#onboarding-connection-clients')).toBeEmpty();
|
||||
await page.locator('#modal-done-btn').click();
|
||||
await expect(page.locator('#modal-create')).not.toHaveClass(/open/);
|
||||
await expect(page.locator('#reveal-key-value')).toHaveText('');
|
||||
|
||||
await page.locator('#key-kind-approval').click();
|
||||
await expect(page.locator('#key-kind-hint')).toContainText(
|
||||
@@ -43,4 +55,253 @@ test('api keys page opens create key flow', async ({ page }) => {
|
||||
await page.locator('#new-key-name').fill(`playwright-approval-${Date.now()}`);
|
||||
await page.locator('#modal-confirm-btn').click();
|
||||
await expect(page.locator('#reveal-key-value')).toContainText('crk_appr_');
|
||||
await expect(page.locator('#onboarding-connection-config')).toBeHidden();
|
||||
const approvalRawKey = await page.locator('#reveal-key-value').textContent();
|
||||
await page.goto('/agents');
|
||||
await page.goBack();
|
||||
await expect(page.locator('#reveal-key-value')).not.toContainText(approvalRawKey || 'crk_appr_');
|
||||
});
|
||||
|
||||
test('failed clipboard write preserves one-time key material', async ({ page }) => { // community-scope: allow=one-time-token
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
await createAgent(page, workspace.id, {
|
||||
slug: uniqueName('playwright_clipboard_agent'),
|
||||
display_name: 'Playwright Clipboard Agent',
|
||||
description: 'Agent for clipboard failure recovery.',
|
||||
instructions: {},
|
||||
tool_selection_policy: {},
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: () => Promise.reject(new Error('clipboard denied')) },
|
||||
});
|
||||
});
|
||||
await page.goto('/api-keys');
|
||||
await page.locator('#btn-create-key').click();
|
||||
await page.locator('#new-key-name').fill(uniqueName('clipboard_failure'));
|
||||
await page.locator('#modal-confirm-btn').click();
|
||||
await expect(page.locator('#reveal-key-value')).toHaveText(/^crk_/);
|
||||
const rawKey = await page.locator('#reveal-key-value').textContent();
|
||||
expect(rawKey).toMatch(/^crk_/);
|
||||
await page.locator('#copy-key-btn').click();
|
||||
await expect(page.locator('#reveal-key-value')).toHaveText(rawKey);
|
||||
await expect(page.locator('#modal-reveal-body')).toContainText(
|
||||
localized('Copy this key now', 'Скопируйте ключ сейчас')
|
||||
);
|
||||
const config = page.getByTestId('onboarding-connection-config');
|
||||
await expect(config).toBeVisible();
|
||||
await config.getByRole('button', { name: localized('Copy configuration', 'Скопировать конфигурацию') }).first().click();
|
||||
await expect(page.locator('#reveal-key-value')).toHaveText(rawKey);
|
||||
await expect(config).toBeVisible();
|
||||
await expect(page.locator('#onboarding-connection-clients')).not.toBeEmpty();
|
||||
});
|
||||
|
||||
test('ambiguous create failure reconciles metadata before deliberate retry', async ({ page }) => {
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
await createAgent(page, workspace.id, {
|
||||
slug: uniqueName('playwright_ambiguous_agent'),
|
||||
display_name: 'Playwright Ambiguous Agent',
|
||||
description: 'Agent for ambiguous key creation recovery.',
|
||||
instructions: {},
|
||||
tool_selection_policy: {},
|
||||
});
|
||||
let failCreate = true;
|
||||
await page.route(/\/api\/admin\/workspaces\/[^/]+\/agents\/[^/]+\/platform-api-keys$/, async (route) => {
|
||||
if (route.request().method() === 'POST' && failCreate) {
|
||||
failCreate = false;
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: { code: 'key_create_outcome_unknown', message: 'unknown' } }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto('/api-keys');
|
||||
await page.locator('#btn-create-key').click();
|
||||
await page.locator('#new-key-name').fill(uniqueName('ambiguous_key'));
|
||||
await page.locator('#modal-confirm-btn').click();
|
||||
await expect(page.getByTestId('ambiguous-create-warning')).toBeVisible();
|
||||
await expect(page.locator('#modal-confirm-btn')).toBeDisabled();
|
||||
await page.locator('#ambiguous-create-retry-btn').click();
|
||||
await expect(page.getByTestId('ambiguous-create-warning')).toBeHidden();
|
||||
await expect(page.locator('#modal-confirm-btn')).toBeEnabled();
|
||||
});
|
||||
|
||||
test('stale create response reconciles metadata without changing a newer modal generation', async ({ page }) => {
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
const agent = await createAgent(page, workspace.id, {
|
||||
slug: uniqueName('playwright_keys_stale_agent'),
|
||||
display_name: 'Playwright Keys Stale Agent',
|
||||
description: 'Agent for API key stale response test.',
|
||||
instructions: {},
|
||||
tool_selection_policy: {},
|
||||
});
|
||||
|
||||
let releaseFirstCreate;
|
||||
let releaseSecondCreate;
|
||||
const firstCreateReleased = new Promise((resolve) => {
|
||||
releaseFirstCreate = resolve;
|
||||
});
|
||||
const secondCreateReleased = new Promise((resolve) => {
|
||||
releaseSecondCreate = resolve;
|
||||
});
|
||||
let createCount = 0;
|
||||
await page.route(/\/api\/admin\/workspaces\/[^/]+\/agents\/[^/]+\/platform-api-keys$/, async (route) => {
|
||||
if (route.request().method() !== 'POST') {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
const requestNumber = ++createCount;
|
||||
await (requestNumber === 1 ? firstCreateReleased : secondCreateReleased);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
secret: requestNumber === 1
|
||||
? 'crk_ui_stale_canary_should_not_render'
|
||||
: 'crk_ui_current_canary_may_render',
|
||||
api_key: {
|
||||
api_key: {
|
||||
id: requestNumber === 1 ? 'pk_ui_stale' : 'pk_ui_current',
|
||||
workspace_id: workspace.id,
|
||||
agent_id: agent.agent_id,
|
||||
key_kind: 'mcp_client',
|
||||
name: requestNumber === 1 ? 'ui-stale-key' : 'ui-current-key',
|
||||
prefix: requestNumber === 1 ? 'crk_ui_stale' : 'crk_ui_current',
|
||||
scopes: ['read'],
|
||||
status: 'active',
|
||||
created_at: '2026-08-15T00:00:00Z',
|
||||
last_used_at: null,
|
||||
expires_at: null,
|
||||
allowed_origins: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/api-keys');
|
||||
await page.locator('#btn-create-key').click();
|
||||
await page.locator('#new-key-name').fill(uniqueName('ui_stale_key'));
|
||||
await page.locator('#modal-confirm-btn').click();
|
||||
await expect(page.locator('#modal-confirm-btn')).toBeDisabled();
|
||||
|
||||
await page.locator('#modal-cancel-btn').click();
|
||||
await expect(page.locator('#modal-create')).not.toHaveClass(/open/);
|
||||
await page.locator('#btn-create-key').click();
|
||||
await page.locator('#new-key-name').fill(uniqueName('ui_current_key'));
|
||||
await page.locator('#modal-confirm-btn').click();
|
||||
const confirm = page.locator('#modal-confirm-btn');
|
||||
await expect(confirm).toBeDisabled();
|
||||
await expect(confirm).toContainText(localized('Creating', 'Создание'));
|
||||
|
||||
releaseFirstCreate();
|
||||
await expect(page.getByTestId('onboarding-key-lost')).toBeVisible();
|
||||
await expect(confirm).toBeDisabled();
|
||||
await expect(confirm).toContainText(localized('Creating', 'Создание'));
|
||||
|
||||
releaseSecondCreate();
|
||||
await expect(page.locator('#modal-reveal-body')).toBeVisible();
|
||||
|
||||
await expect(page.locator('#reveal-key-value')).not.toContainText('crk_ui_stale_canary_should_not_render');
|
||||
await expect(page.locator('#reveal-key-value')).toContainText('crk_ui_current_canary_may_render');
|
||||
});
|
||||
|
||||
test('duplicate key revoke and delete clicks send one request while the first request is pending', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.confirm = () => true;
|
||||
});
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
const agent = await createAgent(page, workspace.id, {
|
||||
slug: uniqueName('playwright_key_mutation_agent'),
|
||||
display_name: 'Playwright Key Mutation Agent',
|
||||
description: 'Agent for duplicate key mutation guards.',
|
||||
instructions: {},
|
||||
tool_selection_policy: {},
|
||||
});
|
||||
const revokeName = uniqueName('revoke_once');
|
||||
const deleteName = uniqueName('delete_once');
|
||||
await browserJson(
|
||||
page,
|
||||
'POST',
|
||||
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/agents/${encodeURIComponent(agent.agent_id)}/platform-api-keys`,
|
||||
{ name: revokeName, key_kind: 'mcp_client', scopes: ['read'] },
|
||||
);
|
||||
const revokedKey = await browserJson(
|
||||
page,
|
||||
'POST',
|
||||
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/agents/${encodeURIComponent(agent.agent_id)}/platform-api-keys`,
|
||||
{ name: deleteName, key_kind: 'mcp_client', scopes: ['read'] },
|
||||
);
|
||||
const revokedKeyId = revokedKey.api_key.api_key.id;
|
||||
await browserJson(
|
||||
page,
|
||||
'POST',
|
||||
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/agents/${encodeURIComponent(agent.agent_id)}/platform-api-keys/${encodeURIComponent(revokedKeyId)}/revoke`,
|
||||
{},
|
||||
[204],
|
||||
);
|
||||
|
||||
let releaseRevoke;
|
||||
const revokeReleased = new Promise((resolve) => {
|
||||
releaseRevoke = resolve;
|
||||
});
|
||||
let revokeRequests = 0;
|
||||
let releaseDelete;
|
||||
const deleteReleased = new Promise((resolve) => {
|
||||
releaseDelete = resolve;
|
||||
});
|
||||
let deleteRequests = 0;
|
||||
await page.route(/\/api\/admin\/workspaces\/[^/]+\/agents\/[^/]+\/platform-api-keys\/[^/]+(?:\/revoke)?$/, async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === 'POST' && /\/revoke$/.test(route.request().url())) {
|
||||
revokeRequests += 1;
|
||||
await revokeReleased;
|
||||
await route.fulfill({ status: 204 });
|
||||
return;
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
deleteRequests += 1;
|
||||
await deleteReleased;
|
||||
await route.fulfill({ status: 204 });
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto('/api-keys');
|
||||
await page.locator('#agent-select').selectOption(agent.agent_id);
|
||||
await expect(page.locator('#keys-tbody')).toContainText(revokeName);
|
||||
await expect(page.locator('#keys-tbody')).toContainText(deleteName);
|
||||
|
||||
await page.evaluate((name) => {
|
||||
const row = Array.from(document.querySelectorAll('#keys-tbody tr'))
|
||||
.find((candidate) => candidate.textContent.includes(name));
|
||||
const button = row.querySelector('[title="Revoke key"]');
|
||||
button.click();
|
||||
button.click();
|
||||
}, revokeName);
|
||||
await expect.poll(() => revokeRequests).toBe(1);
|
||||
releaseRevoke();
|
||||
await expect(page.locator('#keys-tbody')).toContainText(revokeName);
|
||||
|
||||
const deleteRow = page.locator('#keys-tbody tr').filter({ hasText: deleteName }).first();
|
||||
const deleteButton = deleteRow.locator('[title="Delete"]');
|
||||
await expect(deleteRow).toBeVisible();
|
||||
await expect(deleteButton).toBeEnabled();
|
||||
await deleteButton.evaluate((button) => {
|
||||
button.click();
|
||||
button.click();
|
||||
});
|
||||
await expect.poll(() => deleteRequests).toBe(1);
|
||||
releaseDelete();
|
||||
await expect(page.locator('#keys-tbody')).toContainText(deleteName);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { login, localized } = require('./helpers');
|
||||
|
||||
test('logs approval panel renders safe pending approval metadata', async ({ page }) => {
|
||||
await login(page);
|
||||
let approveCalled = false;
|
||||
|
||||
await page.route(/\/api\/admin\/workspaces\/[^/]+\/approvals(?:\?.*)?$/, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
approval: {
|
||||
id: 'approval_ui_safe_summary',
|
||||
agent_id: 'agent_sales',
|
||||
operation_id: 'op_charge_customer',
|
||||
operation_version: 3,
|
||||
status: 'pending',
|
||||
risk_level: 'dangerous',
|
||||
request_id: 'req_approval_ui',
|
||||
trace_id: '0af7651916cd43dd8448eb211c80319c',
|
||||
request_payload: {
|
||||
email: 'customer@example.com',
|
||||
api_key: '[REDACTED]',
|
||||
},
|
||||
response_payload: null,
|
||||
created_at: '2026-08-22T10:00:00Z',
|
||||
expires_at: '2026-08-22T10:05:00Z',
|
||||
decided_at: null,
|
||||
decision_note: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route(/\/api\/admin\/workspaces\/[^/]+\/approvals\/approval_ui_safe_summary\/approve$/, async (route) => {
|
||||
approveCalled = true;
|
||||
const body = JSON.parse(route.request().postData() || '{}');
|
||||
expect(body).toEqual({ approve: 'yes' });
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
approval: {
|
||||
id: 'approval_ui_safe_summary',
|
||||
agent_id: 'agent_sales',
|
||||
operation_id: 'op_charge_customer',
|
||||
operation_version: 3,
|
||||
status: 'approved',
|
||||
risk_level: 'dangerous',
|
||||
request_id: 'req_approval_ui',
|
||||
trace_id: '0af7651916cd43dd8448eb211c80319c',
|
||||
request_payload: { email: 'customer@example.com', api_key: '[REDACTED]' },
|
||||
response_payload: { approve: 'yes' },
|
||||
created_at: '2026-08-22T10:00:00Z',
|
||||
expires_at: '2026-08-22T10:05:00Z',
|
||||
decided_at: '2026-08-22T10:01:00Z',
|
||||
decision_note: null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
page.on('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toMatch(localized(
|
||||
'Approve this pending confirmation request?',
|
||||
'Подтвердить эту заявку?',
|
||||
));
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
await page.goto('/logs');
|
||||
await expect(page.locator('.approval-panel-title')).toHaveText(
|
||||
localized('Human confirmations', 'Подтверждения человеком'),
|
||||
);
|
||||
await expect(page.locator('#approval-list')).toContainText('approval_ui_safe_summary');
|
||||
await expect(page.locator('#approval-list')).toContainText('op_charge_customer v3');
|
||||
await expect(page.locator('#approval-list')).toContainText('agent_sales');
|
||||
await expect(page.locator('#approval-list')).toContainText('customer@example.com');
|
||||
await expect(page.locator('#approval-list')).toContainText('[REDACTED]');
|
||||
await expect(page.locator('#approval-list')).toContainText('req_approval_ui');
|
||||
await expect(page.locator('#approval-list')).toContainText('0af7651916cd43dd8448eb211c80319c');
|
||||
await expect(page.locator('.approval-approve')).toHaveText(localized('Approve', 'Подтвердить'));
|
||||
await expect(page.locator('.approval-deny')).toHaveText(localized('Deny', 'Отклонить'));
|
||||
await expect(page.locator('#approval-list')).not.toContainText('SECRET_APPROVAL_CANARY');
|
||||
await page.locator('.approval-approve').click();
|
||||
await expect.poll(() => approveCalled).toBe(true);
|
||||
});
|
||||
@@ -28,6 +28,14 @@ async function browserJson(page, method, urlPath, body, okStatuses = [200]) {
|
||||
request.headers['Content-Type'] = 'application/json';
|
||||
request.body = JSON.stringify(body);
|
||||
}
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase())) {
|
||||
let csrfToken = window.CrankAuth && window.CrankAuth.getCsrfToken();
|
||||
if (!csrfToken && window.CrankApi && window.CrankApi.refreshSessionCsrf) {
|
||||
const csrf = await window.CrankApi.refreshSessionCsrf();
|
||||
csrfToken = csrf && csrf.csrf_token;
|
||||
}
|
||||
if (csrfToken) request.headers['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
|
||||
const result = await fetch(urlPath, request);
|
||||
const text = await result.text();
|
||||
@@ -66,11 +74,17 @@ async function getCurrentWorkspace(page) {
|
||||
}
|
||||
|
||||
async function createAgent(page, workspaceId, payload) {
|
||||
return browserJson(
|
||||
page,
|
||||
'POST',
|
||||
`/api/admin/workspaces/${encodeURIComponent(workspaceId)}/agents`,
|
||||
payload,
|
||||
return page.evaluate(
|
||||
async ({ workspaceId, payload }) => {
|
||||
var session = await window.CrankApi.getSession();
|
||||
if (!session.csrf_token) {
|
||||
var csrf = await window.CrankApi.refreshSessionCsrf();
|
||||
session.csrf_token = csrf.csrf_token;
|
||||
}
|
||||
window.CrankAuth.replaceSession(session);
|
||||
return window.CrankApi.createAgent(workspaceId, payload);
|
||||
},
|
||||
{ workspaceId, payload },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,43 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { ADMIN_EMAIL, ADMIN_PASSWORD, localized } = require('./helpers');
|
||||
|
||||
const SESSION_FIXTURE = {
|
||||
user: {
|
||||
id: 'user-test',
|
||||
email: ADMIN_EMAIL,
|
||||
display_name: 'Crank Owner',
|
||||
},
|
||||
memberships: [
|
||||
{
|
||||
role: 'owner',
|
||||
workspace: {
|
||||
id: 'workspace-test',
|
||||
slug: 'default',
|
||||
name: 'Default Workspace',
|
||||
},
|
||||
},
|
||||
],
|
||||
current_workspace_id: 'workspace-test',
|
||||
csrf_token: 'csrf_test_token',
|
||||
};
|
||||
|
||||
async function stubLoginPageSession(page, bootstrapRequired) {
|
||||
await page.route('**/api/auth/session', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: { message: 'unauthorized' } }),
|
||||
});
|
||||
});
|
||||
await page.route('**/api/auth/bootstrap/status', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ bootstrap_required: bootstrapRequired }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('login page rejects invalid credentials', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
@@ -10,6 +47,29 @@ test('login page rejects invalid credentials', async ({ page }) => {
|
||||
});
|
||||
|
||||
test('login page signs in and redirects to operations', async ({ page }) => {
|
||||
let loggedIn = false;
|
||||
await page.route('**/api/auth/session', async (route) => {
|
||||
await route.fulfill({
|
||||
status: loggedIn ? 200 : 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(loggedIn ? SESSION_FIXTURE : { error: { message: 'unauthorized' } }),
|
||||
});
|
||||
});
|
||||
await page.route('**/api/auth/bootstrap/status', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ bootstrap_required: false }),
|
||||
});
|
||||
});
|
||||
await page.route('**/api/auth/login', async (route) => {
|
||||
loggedIn = true;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(SESSION_FIXTURE),
|
||||
});
|
||||
});
|
||||
await page.goto('/login');
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
@@ -17,3 +77,53 @@ test('login page signs in and redirects to operations', async ({ page }) => {
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(page.locator('.page-title, .page-heading').first()).toHaveText(localized('Operations', 'Операции'));
|
||||
});
|
||||
|
||||
test('login page switches to bootstrap mode and submits one-time token', async ({ page }) => { // community-scope: allow=one-time-token
|
||||
await stubLoginPageSession(page, true);
|
||||
let completePayload = null;
|
||||
await page.route('**/api/auth/bootstrap/complete', async (route) => {
|
||||
completePayload = route.request().postDataJSON();
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(SESSION_FIXTURE),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/login');
|
||||
await expect(page.locator('#login-form')).toHaveAttribute('data-bootstrap', 'true');
|
||||
await expect(page.locator('#login-email-field')).toBeHidden();
|
||||
await expect(page.locator('#login-bootstrap-token-field')).toBeVisible();
|
||||
await page.locator('#bootstrap-token').fill('boot_test_one_time_token'); // community-scope: allow=one-time-token
|
||||
await page.locator('#password').fill('new-admin-password');
|
||||
await page.locator('.btn-signin').click();
|
||||
|
||||
await expect.poll(() => completePayload).toMatchObject({
|
||||
token: 'boot_test_one_time_token', // community-scope: allow=one-time-token
|
||||
password: 'new-admin-password',
|
||||
});
|
||||
});
|
||||
|
||||
test('CrankApi attaches csrf token to unsafe auth requests', async ({ page }) => {
|
||||
await stubLoginPageSession(page, false);
|
||||
let csrfHeader = null;
|
||||
await page.route('**/api/auth/password', async (route) => {
|
||||
csrfHeader = route.request().headers()['x-csrf-token'] || null;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/login');
|
||||
await page.evaluate((session) => {
|
||||
window.CrankAuth.replaceSession(session);
|
||||
}, SESSION_FIXTURE);
|
||||
await page.evaluate(() => window.CrankApi.changePassword({
|
||||
current_password: 'old-password',
|
||||
new_password: 'new-password',
|
||||
}));
|
||||
|
||||
await expect.poll(() => csrfHeader).toBe('csrf_test_token');
|
||||
});
|
||||
|
||||
@@ -6,11 +6,17 @@ test('logs and usage pages show seeded data', async ({ page }) => {
|
||||
|
||||
await page.goto('/logs');
|
||||
await expect(page.locator('.page-title')).toHaveText(localized('Logs', 'Логи'));
|
||||
await expect(page.locator('#status-filter')).toBeVisible();
|
||||
await expect(page.locator('#export-logs-btn')).toBeVisible();
|
||||
await expect(page.locator('#log-list')).toBeVisible();
|
||||
await expect(page.locator('#log-list').locator('.empty-state, .log-entry, .log-row, .log-item').first()).toBeVisible();
|
||||
await page.locator('#status-filter').selectOption('ok');
|
||||
await expect(page.locator('#log-list').locator('.empty-state, .log-entry, .log-row, .log-item').first()).toBeVisible();
|
||||
|
||||
await page.goto('/usage');
|
||||
await expect(page.locator('.page-title')).toHaveText(localized('Usage', 'Использование'));
|
||||
await expect(page.locator('#chart-bars .chart-col')).toHaveCount(7);
|
||||
await expect(page.locator('#usage-outcome-list')).toBeVisible();
|
||||
await expect(page.locator('#usage-outcome-list').locator('.empty-state, .resource-card').first()).toBeVisible();
|
||||
await expect(page.locator('#usage-tbody tr')).toHaveCount(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const {
|
||||
browserJson,
|
||||
createAgent,
|
||||
getCurrentWorkspace,
|
||||
login,
|
||||
uniqueName,
|
||||
} = require('./helpers');
|
||||
|
||||
function onboardingSnapshot(workspaceId, overrides = {}) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
workspace_id: workspaceId,
|
||||
revision: 1,
|
||||
status: 'in_progress',
|
||||
dismissed: false,
|
||||
eligible_since: '2026-08-23T08:00:00Z',
|
||||
started_at: '2026-08-23T08:01:00Z',
|
||||
completed_at: null,
|
||||
steps: [
|
||||
{ kind: 'operation', status: 'complete', action: 'open_operation' },
|
||||
{ kind: 'test', status: 'complete', action: 'open_operation' },
|
||||
{ kind: 'publish', status: 'complete', action: 'open_operation' },
|
||||
{ kind: 'agent', status: 'current', action: 'create_agent' },
|
||||
{ kind: 'key', status: 'pending', action: 'create_key' },
|
||||
{ kind: 'connection', status: 'pending', action: 'show_connection' },
|
||||
{ kind: 'first_call', status: 'pending', action: 'open_logs' },
|
||||
],
|
||||
operation: {
|
||||
id: 'op_onboarding_contract',
|
||||
published_version: 1,
|
||||
last_test: {
|
||||
status: 'ok',
|
||||
request_id: 'req_onboarding_test',
|
||||
trace_id: '0123456789abcdef0123456789abcdef',
|
||||
occurred_at: '2026-08-23T08:02:00Z',
|
||||
},
|
||||
},
|
||||
agent: null,
|
||||
key: null,
|
||||
connection: { status: 'pending', reason_code: 'key_required' },
|
||||
first_call: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function routeOnboarding(page, responder) {
|
||||
await page.route(/\/api\/admin\/workspaces\/[^/]+\/onboarding(?:\/events)?(?:\?.*)?$/, responder);
|
||||
}
|
||||
|
||||
async function parseMcpResponse(response) {
|
||||
const text = await response.text();
|
||||
const dataLine = text.split(/\r?\n/).find((line) => line.startsWith('data:'));
|
||||
return JSON.parse(dataLine ? dataLine.slice(5).trim() : text);
|
||||
}
|
||||
|
||||
async function mcpPost(request, endpoint, key, payload, sessionId) {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${key}`,
|
||||
Accept: 'application/json, text/event-stream',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (sessionId) {
|
||||
headers['MCP-Session-Id'] = sessionId;
|
||||
headers['MCP-Protocol-Version'] = '2025-06-18';
|
||||
}
|
||||
return request.post(endpoint, { headers, data: payload });
|
||||
}
|
||||
|
||||
test('optional Getting Started checklist is server-derived, resumable, bilingual and accessible', async ({ page }) => {
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
let snapshotRequests = 0;
|
||||
const presentationEvents = [];
|
||||
|
||||
await routeOnboarding(page, async (route) => {
|
||||
snapshotRequests += 1;
|
||||
if (route.request().method() === 'POST') {
|
||||
presentationEvents.push((await route.request().postDataJSON()).event);
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(onboardingSnapshot(workspace.id)),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
const trigger = page.getByTestId('onboarding-trigger');
|
||||
await expect(trigger).toContainText(/Getting Started|Начало работы/i);
|
||||
|
||||
const checklist = page.getByTestId('onboarding-checklist');
|
||||
if (!await checklist.isVisible()) await trigger.click();
|
||||
await expect(checklist).toBeVisible();
|
||||
await expect.poll(() => presentationEvents).toContain('started');
|
||||
await expect(checklist.locator('ol > li')).toHaveCount(7);
|
||||
await expect(checklist.locator('[aria-current="step"]')).toContainText(/Agent|Агент/i);
|
||||
await expect(checklist.getByRole('status')).toContainText(/3\s*\/\s*7/);
|
||||
await expect(checklist.getByRole('button', { name: /Create agent|Создать агента/i })).toBeFocused();
|
||||
|
||||
await page.getByTestId('onboarding-collapse').click();
|
||||
await expect(checklist).toBeHidden();
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('onboarding-trigger')).toBeVisible();
|
||||
expect(snapshotRequests).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await page.getByTestId('onboarding-trigger').click();
|
||||
await page.getByTestId('onboarding-dismiss').click();
|
||||
await expect(checklist).toBeHidden();
|
||||
await expect(page.getByTestId('onboarding-trigger')).toContainText(/Resume Getting Started|Продолжить начало работы/i);
|
||||
await expect.poll(() => presentationEvents).toEqual(expect.arrayContaining(['dismissed', 'abandoned']));
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('onboarding-trigger')).toContainText(/Resume Getting Started|Продолжить начало работы/i);
|
||||
await page.getByTestId('onboarding-trigger').click();
|
||||
await expect(checklist).toBeVisible();
|
||||
await expect(page.getByTestId('onboarding-trigger')).toContainText(/Getting Started|Начало работы/i);
|
||||
|
||||
await page.evaluate(() => localStorage.setItem('crank_lang', 'ru'));
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('onboarding-trigger')).toContainText('Начало работы');
|
||||
await page.getByTestId('onboarding-trigger').click();
|
||||
await expect(page.getByTestId('onboarding-checklist')).toContainText(/агент/i);
|
||||
await page.evaluate(() => window.setLang('en'));
|
||||
await expect(page.getByTestId('onboarding-checklist').locator('.onboarding-title')).toHaveText('Getting Started');
|
||||
await expect(page.getByTestId('onboarding-collapse')).toHaveAttribute('aria-label', /Collapse Getting Started/i);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByTestId('onboarding-checklist')).toBeHidden();
|
||||
await expect(page.getByTestId('onboarding-trigger')).toBeFocused();
|
||||
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.getByTestId('onboarding-trigger').click();
|
||||
await expect(page.getByTestId('onboarding-checklist')).toHaveCSS('scroll-behavior', 'auto');
|
||||
});
|
||||
|
||||
test('onboarding error exposes safe support correlation and retries without a client completion bypass', async ({ page }) => {
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
let fail = false;
|
||||
let calls = 0;
|
||||
await routeOnboarding(page, async (route) => {
|
||||
calls += 1;
|
||||
if (fail) {
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'x-request-id': 'req_onboarding_retry',
|
||||
'x-trace-id': '0123456789abcdef0123456789abcdef',
|
||||
},
|
||||
body: JSON.stringify({ error: { code: 'onboarding_unavailable', message: 'unavailable' } }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 2 })) });
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByTestId('onboarding-trigger').click();
|
||||
fail = true;
|
||||
await page.getByTestId('onboarding-refresh').click();
|
||||
const alert = page.getByRole('alert');
|
||||
await expect(alert).toContainText('onboarding_unavailable');
|
||||
await expect(alert).toContainText('req_onboarding_retry');
|
||||
await expect(alert).toContainText('0123456789abcdef0123456789abcdef');
|
||||
await expect(page.getByTestId('onboarding-retry')).toBeFocused();
|
||||
fail = false;
|
||||
await page.getByTestId('onboarding-retry').click();
|
||||
await expect(page.getByTestId('onboarding-checklist').locator('ol > li')).toHaveCount(7);
|
||||
expect(calls).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('validation, upstream and compatibility errors recover from the last authoritative onboarding step', async ({ page }) => {
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
const failures = [
|
||||
{ status: 422, code: 'onboarding_validation_failed' },
|
||||
{ status: 502, code: 'onboarding_upstream_unavailable' },
|
||||
{ status: 426, code: 'onboarding_client_incompatible' },
|
||||
];
|
||||
let failureIndex = -1;
|
||||
await routeOnboarding(page, async (route) => {
|
||||
if (failureIndex >= 0) {
|
||||
const failure = failures[failureIndex];
|
||||
await route.fulfill({
|
||||
status: failure.status,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'x-request-id': `req_onboarding_${failure.status}`,
|
||||
'x-trace-id': '0123456789abcdef0123456789abcdef',
|
||||
},
|
||||
body: JSON.stringify({ error: { code: failure.code, message: 'safe failure' } }),
|
||||
});
|
||||
failureIndex = -1;
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 3 })) });
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByTestId('onboarding-trigger').click();
|
||||
for (let index = 0; index < failures.length; index += 1) {
|
||||
failureIndex = index;
|
||||
await page.getByTestId('onboarding-refresh').click();
|
||||
const alert = page.getByRole('alert');
|
||||
await expect(alert).toContainText(failures[index].code);
|
||||
await expect(alert).toContainText(`req_onboarding_${failures[index].status}`);
|
||||
await expect(alert).toContainText('0123456789abcdef0123456789abcdef');
|
||||
await page.getByTestId('onboarding-retry').click();
|
||||
await expect(page.getByTestId('onboarding-trigger')).toContainText(/3\s*\/\s*7/);
|
||||
await expect(page.getByTestId('onboarding-checklist').locator('ol > li')).toHaveCount(7);
|
||||
await expect(page.getByTestId('onboarding-completion')).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('authentication recovery returns to an incomplete authoritative onboarding projection', async ({ page }) => {
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
let rejectOnce = false;
|
||||
await routeOnboarding(page, async (route) => {
|
||||
if (rejectOnce) {
|
||||
rejectOnce = false;
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: { code: 'onboarding_auth_required', message: 'sign in again' } }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 4 })) });
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByTestId('onboarding-trigger').click();
|
||||
rejectOnce = true;
|
||||
await page.getByTestId('onboarding-refresh').click();
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await page.context().clearCookies();
|
||||
await login(page);
|
||||
await expect(page.getByTestId('onboarding-trigger')).toContainText(/3\s*\/\s*7/);
|
||||
await expect(page.getByTestId('onboarding-completion')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a BroadcastChannel refresh updates a second open tab from server progress', async ({ page, context }) => {
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
var revision = 1;
|
||||
const respond = async (route) => route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(onboardingSnapshot(workspace.id, {
|
||||
revision,
|
||||
steps: revision === 1 ? onboardingSnapshot(workspace.id).steps : onboardingSnapshot(workspace.id).steps.map((step) => ({ ...step, status: 'complete' })),
|
||||
})),
|
||||
});
|
||||
await routeOnboarding(page, respond);
|
||||
await page.goto('/');
|
||||
await expect(page.getByTestId('onboarding-trigger')).toContainText(/3\s*\/\s*7/);
|
||||
|
||||
const other = await context.newPage();
|
||||
await routeOnboarding(other, respond);
|
||||
await other.goto('/');
|
||||
await expect(other.locator('.page-title, .page-heading').first()).toBeVisible();
|
||||
revision = 2;
|
||||
await other.evaluate(() => window.CrankOnboarding.signalRefresh());
|
||||
await expect(page.getByTestId('onboarding-trigger')).toContainText(/7\s*\/\s*7/);
|
||||
await other.close();
|
||||
});
|
||||
|
||||
test('bursty browser refresh signals coalesce into one request plus one trailing refresh', async ({ page }) => {
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
let requestNumber = 0;
|
||||
let releaseRefresh;
|
||||
const refreshReleased = new Promise((resolve) => { releaseRefresh = resolve; });
|
||||
|
||||
await routeOnboarding(page, async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 1 })) });
|
||||
return;
|
||||
}
|
||||
requestNumber += 1;
|
||||
if (requestNumber === 1) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 1 })),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (requestNumber === 2) await refreshReleased;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(onboardingSnapshot(workspace.id, {
|
||||
revision: 2,
|
||||
steps: onboardingSnapshot(workspace.id).steps.map((step) => ({ ...step, status: 'complete' })),
|
||||
status: 'complete',
|
||||
completed_at: '2026-08-23T08:10:00Z',
|
||||
first_call: {
|
||||
log_id: 'log_newest',
|
||||
tool_name: 'frankfurter_latest_rate',
|
||||
request_id: 'req_newest',
|
||||
trace_id: 'abcdefabcdefabcdefabcdefabcdefab',
|
||||
occurred_at: '2026-08-23T08:10:00Z',
|
||||
},
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
if (!await page.getByTestId('onboarding-checklist').isVisible()) {
|
||||
await page.getByTestId('onboarding-trigger').click();
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
window.CrankOnboarding.refresh();
|
||||
window.CrankOnboarding.refresh();
|
||||
window.CrankOnboarding.refresh();
|
||||
window.CrankOnboarding.refresh();
|
||||
});
|
||||
await expect.poll(() => requestNumber).toBe(2);
|
||||
releaseRefresh();
|
||||
await expect(page.getByTestId('onboarding-trigger')).toContainText(/7\s*\/\s*7/);
|
||||
await expect(page.getByTestId('onboarding-completion')).toContainText('req_newest');
|
||||
await expect(page.getByTestId('onboarding-completion')).toContainText('frankfurter_latest_rate');
|
||||
await expect(page.getByTestId('onboarding-completion')).toContainText('2026-08-23T08:10:00Z');
|
||||
await expect.poll(() => requestNumber).toBe(3);
|
||||
});
|
||||
|
||||
test('stale onboarding deep link requires server reset before explicit reselection', async ({ page }) => {
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
let resetPayload = null;
|
||||
await routeOnboarding(page, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(onboardingSnapshot(workspace.id, {
|
||||
revision: 17,
|
||||
operation_id: 'op_current',
|
||||
operation_version: 4,
|
||||
agent_id: 'agent_current',
|
||||
catalog_revision: 9,
|
||||
})),
|
||||
});
|
||||
});
|
||||
await page.route(/\/api\/admin\/workspaces\/[^/]+\/onboarding\/reset-selection$/, async (route) => {
|
||||
resetPayload = await route.request().postDataJSON();
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 18 })),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/agents?onboarding=1&action=create&operationId=op_stale&operationVersion=3');
|
||||
const recovery = page.getByTestId('onboarding-deep-link-stale');
|
||||
await expect(recovery).toBeVisible();
|
||||
await expect(recovery).toContainText(/changed Operation|изменённой операции/i);
|
||||
await page.getByTestId('onboarding-reselect').click();
|
||||
await expect.poll(() => resetPayload).toEqual({ expected_revision: 17 });
|
||||
await expect(page).toHaveURL('/agents');
|
||||
});
|
||||
|
||||
test('one-time key connection config is never recoverable after the reveal is lost', async ({ page }) => { // community-scope: allow=one-time-token
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
const agent = await createAgent(page, workspace.id, {
|
||||
slug: uniqueName('onboarding_lost_key'),
|
||||
display_name: 'Onboarding lost key Agent',
|
||||
description: 'Proves one-time onboarding key handling.', // community-scope: allow=one-time-token
|
||||
instructions: {},
|
||||
tool_selection_policy: { mode: 'direct', groups: [], search: { max_results: 8 } },
|
||||
});
|
||||
|
||||
await page.goto(`/api-keys?onboarding=1&action=create&agentId=${encodeURIComponent(agent.agent_id)}`);
|
||||
await page.waitForFunction(() => Boolean(window.CrankAuth && window.CrankAuth.getCsrfToken()));
|
||||
await expect(page.locator('#agent-select')).toHaveValue(agent.agent_id);
|
||||
await expect(page.locator('#modal-create')).toHaveClass(/open/);
|
||||
await page.locator('#new-key-name').fill(uniqueName('onboarding_client'));
|
||||
await page.locator('#modal-confirm-btn').click();
|
||||
|
||||
const revealKey = page.locator('#reveal-key-value');
|
||||
await expect(revealKey).toHaveText(/^crk_/);
|
||||
const rawKey = await revealKey.textContent();
|
||||
await expect(page.getByTestId('onboarding-connection-config')).toContainText('/mcp/v1/');
|
||||
await expect(page.getByTestId('onboarding-clipboard-warning')).toBeVisible();
|
||||
|
||||
const leakedBeforeNavigation = await page.evaluate((secret) => ({
|
||||
local: Object.values(localStorage).some((value) => String(value).includes(secret)),
|
||||
session: Object.values(sessionStorage).some((value) => String(value).includes(secret)),
|
||||
url: location.href.includes(secret),
|
||||
}), rawKey);
|
||||
expect(leakedBeforeNavigation).toEqual({ local: false, session: false, url: false });
|
||||
|
||||
await page.goto('/agents');
|
||||
await page.goBack();
|
||||
await expect(page.locator('body')).not.toContainText(rawKey);
|
||||
await expect(page.getByTestId('onboarding-key-lost')).toContainText(/create|rotate|созда|ротац/i);
|
||||
await expect(page.getByTestId('onboarding-connection-config')).toBeHidden();
|
||||
await expect(page.locator('#onboarding-connection-clients')).toBeEmpty();
|
||||
});
|
||||
|
||||
test('first value is proven only by a real tools/call through the public MCP endpoint', async ({ page, request }) => {
|
||||
test.setTimeout(90_000);
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
await page.waitForFunction(() => Boolean(window.CrankAuth && window.CrankAuth.getCsrfToken()));
|
||||
|
||||
const suffix = uniqueName('mcp').replace(/_/g, '-');
|
||||
const operationName = uniqueName('onboarding_real_rest');
|
||||
const agentSlug = `onboarding-real-${suffix}`.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
|
||||
// Exercise the actual wizard controls and lifecycle buttons. This is not an
|
||||
// API provisioning shortcut: every mutation below is initiated by visible UI.
|
||||
await page.goto('/wizard/?onboarding=1');
|
||||
await page.waitForFunction(() => window.CrankWizardReady === true && window.currentStep === 1);
|
||||
await page.locator('[data-testid="wizard-protocol-rest"]').click();
|
||||
await page.locator('#btn-continue').click();
|
||||
await expect(page.locator('#upstream-new-trigger')).toBeVisible();
|
||||
await page.locator('#upstream-new-trigger').click();
|
||||
await page.locator('#new-upstream-name').fill(`onboarding-${suffix}`);
|
||||
await page.locator('#new-upstream-url').fill('http://127.0.0.1:3310');
|
||||
await page.locator('#new-upstream-static-headers').fill('{"Accept":"application/json"}');
|
||||
await page.locator('[data-wizard-action="save-upstream"]').click();
|
||||
await expect(page.locator('#upstream-preview-url')).toHaveText('http://127.0.0.1:3310');
|
||||
await page.locator('#endpoint-path').fill('/rates');
|
||||
await page.locator('#btn-continue').click();
|
||||
await page.locator('.method-card[data-method="GET"]').click();
|
||||
await page.locator('#btn-continue').click();
|
||||
await page.locator('#tool-name').fill(operationName);
|
||||
await page.locator('#tool-display-name').fill('Deterministic onboarding REST Operation');
|
||||
await page.locator('#tool-title').fill('Deterministic onboarding REST Operation');
|
||||
await page.locator('#tool-description').fill(
|
||||
'A local deterministic REST target exercised through the complete onboarding UI flow.',
|
||||
);
|
||||
await page.locator('#tool-input-schema').fill(JSON.stringify({
|
||||
type: 'object',
|
||||
properties: {
|
||||
base: { type: 'string', description: 'Base currency.' },
|
||||
quote: { type: 'string', description: 'Quote currency.' },
|
||||
},
|
||||
required: ['base', 'quote'],
|
||||
}));
|
||||
await page.locator('#tool-output-schema').fill(JSON.stringify({
|
||||
type: 'object',
|
||||
properties: { base: { type: 'string', description: 'Returned base currency.' } },
|
||||
required: ['base'],
|
||||
}));
|
||||
await page.locator('#btn-continue').click();
|
||||
await page.locator('details.advanced-mapping-details').filter({ has: page.locator('#tool-input-mapping') }).locator('summary').click();
|
||||
await page.locator('#tool-input-mapping').fill(JSON.stringify({
|
||||
'query.base': '$.input.base',
|
||||
'query.quote': '$.input.quote',
|
||||
}));
|
||||
await page.locator('details.advanced-mapping-details').filter({ has: page.locator('#tool-output-mapping') }).locator('summary').click();
|
||||
await page.locator('#tool-output-mapping').fill(JSON.stringify({
|
||||
base: '$.response.body.base',
|
||||
}));
|
||||
await page.evaluate(() => window.CrankWizardMapping.renderFromEditors());
|
||||
await page.locator('#tool-exec-config').fill('{"timeout_ms":2000,"headers":{}}');
|
||||
await page.locator('#wizard-test-input').fill('{"base":"USD","quote":"EUR"}');
|
||||
|
||||
const createOperationResponse = page.waitForResponse((response) => (
|
||||
response.request().method() === 'POST'
|
||||
&& /\/api\/admin\/workspaces\/[^/]+\/operations$/.test(response.url())
|
||||
));
|
||||
await page.locator('.btn-save-draft').click();
|
||||
const createdOperation = await (await createOperationResponse).json();
|
||||
const operationId = createdOperation.operation_id;
|
||||
await expect(page).toHaveURL(new RegExp(`operationId=${operationId}`));
|
||||
|
||||
const testRunResponse = page.waitForResponse((response) => (
|
||||
response.request().method() === 'POST'
|
||||
&& response.url().includes(`/operations/${operationId}/test-runs`)
|
||||
));
|
||||
await page.locator('#wizard-run-test').click();
|
||||
const testRun = await (await testRunResponse).json();
|
||||
expect(testRun.ok).toBe(true);
|
||||
await expect(page.locator('#wizard-test-request-id')).not.toHaveText('');
|
||||
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
const publishOperationResponse = page.waitForResponse((response) => (
|
||||
response.request().method() === 'POST'
|
||||
&& response.url().includes(`/operations/${operationId}/publish`)
|
||||
));
|
||||
await page.locator('#wizard-publish-operation').click();
|
||||
const published = await (await publishOperationResponse).json();
|
||||
expect(published.published_version).toBe(1);
|
||||
|
||||
// Continue through the actual onboarding Agent drawer. The deep link selects
|
||||
// the exact published Operation/version; the visible Create action publishes it.
|
||||
await page.goto(`/agents?onboarding=1&action=create&operationId=${encodeURIComponent(operationId)}&operationVersion=1`);
|
||||
await expect(page.locator('.drawer')).toHaveClass(/open/);
|
||||
const identityInputs = page.locator('.drawer-section').first().locator('input.form-input');
|
||||
await identityInputs.nth(0).fill('Onboarding real public MCP Agent');
|
||||
await identityInputs.nth(1).fill(agentSlug);
|
||||
await page.locator('.drawer-section').first().locator('textarea').fill(
|
||||
'Real public MCP onboarding acceptance flow.',
|
||||
);
|
||||
await expect(page.locator('.ops-picker-item.selected')).toContainText(operationName);
|
||||
const createAgentResponse = page.waitForResponse((response) => (
|
||||
response.request().method() === 'POST'
|
||||
&& /\/api\/admin\/workspaces\/[^/]+\/agents$/.test(response.url())
|
||||
));
|
||||
const bindAgentResponse = page.waitForResponse((response) => (
|
||||
response.request().method() === 'POST' && /\/agents\/[^/]+\/bindings$/.test(response.url())
|
||||
));
|
||||
const publishAgentResponse = page.waitForResponse((response) => (
|
||||
response.request().method() === 'POST' && /\/agents\/[^/]+\/publish$/.test(response.url())
|
||||
));
|
||||
await page.locator('.drawer-footer .btn-primary-sm').click();
|
||||
const createdResponse = await createAgentResponse;
|
||||
const bindingResponse = await bindAgentResponse;
|
||||
expect(bindingResponse.ok(), await bindingResponse.text()).toBe(true);
|
||||
const agentPublishResponse = await publishAgentResponse;
|
||||
expect(agentPublishResponse.ok(), await agentPublishResponse.text()).toBe(true);
|
||||
const createdAgent = await createdResponse.json();
|
||||
const provisioned = {
|
||||
agentId: createdAgent.agent_id,
|
||||
operationId,
|
||||
operationVersion: 1,
|
||||
toolName: operationName,
|
||||
};
|
||||
await expect(page.locator('.drawer')).not.toHaveClass(/open/);
|
||||
|
||||
// The key is likewise created through the visible onboarding key flow.
|
||||
await page.goto(`/api-keys?onboarding=1&action=create&agentId=${encodeURIComponent(provisioned.agentId)}`);
|
||||
await expect(page.locator('#agent-select')).toHaveValue(provisioned.agentId);
|
||||
await expect(page.locator('#modal-create')).toHaveClass(/open/);
|
||||
await expect(page.locator('[data-scope="read"]')).toBeChecked();
|
||||
await expect(page.locator('[data-scope="write"]')).toBeChecked();
|
||||
await page.locator('#new-key-name').fill(uniqueName('onboarding_real_key'));
|
||||
const keyResponsePromise = page.waitForResponse((response) => (
|
||||
response.request().method() === 'POST'
|
||||
&& /\/platform-api-keys$/.test(response.url())
|
||||
));
|
||||
await page.locator('#modal-confirm-btn').click();
|
||||
const keyResponse = await keyResponsePromise;
|
||||
expect(keyResponse.ok()).toBe(true);
|
||||
const keyBody = await keyResponse.json();
|
||||
provisioned.key = await page.locator('#reveal-key-value').textContent();
|
||||
provisioned.keyId = keyBody.api_key.id || keyBody.api_key.api_key.id;
|
||||
provisioned.endpoint = await page.locator('#onboarding-connection-clients > code').textContent();
|
||||
expect(provisioned.key).toMatch(/^crk_/);
|
||||
await expect(page.getByTestId('onboarding-connection-config')).toContainText(provisioned.endpoint);
|
||||
|
||||
expect(provisioned.endpoint).toContain('/mcp/v1/');
|
||||
const initialize = await mcpPost(request, provisioned.endpoint, provisioned.key, {
|
||||
jsonrpc: '2.0', id: 1, method: 'initialize', params: {
|
||||
protocolVersion: '2025-06-18',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'crank-onboarding-playwright', version: '1.0.0' },
|
||||
},
|
||||
});
|
||||
const initializeBody = await parseMcpResponse(initialize);
|
||||
expect(initialize.ok(), JSON.stringify(initializeBody)).toBe(true);
|
||||
expect(initializeBody.result.protocolVersion).toBe('2025-06-18');
|
||||
const sessionId = initialize.headers()['mcp-session-id'];
|
||||
expect(sessionId).toBeTruthy();
|
||||
|
||||
const initialized = await mcpPost(request, provisioned.endpoint, provisioned.key, {
|
||||
jsonrpc: '2.0', method: 'notifications/initialized', params: {},
|
||||
}, sessionId);
|
||||
expect([200, 202]).toContain(initialized.status());
|
||||
|
||||
const listed = await mcpPost(request, provisioned.endpoint, provisioned.key, {
|
||||
jsonrpc: '2.0', id: 2, method: 'tools/list', params: {},
|
||||
}, sessionId);
|
||||
expect(listed.ok()).toBe(true);
|
||||
const listedBody = await parseMcpResponse(listed);
|
||||
expect(listedBody.result.tools.map((tool) => tool.name)).toContain(provisioned.toolName);
|
||||
|
||||
const called = await mcpPost(request, provisioned.endpoint, provisioned.key, {
|
||||
jsonrpc: '2.0', id: 3, method: 'tools/call', params: {
|
||||
name: provisioned.toolName,
|
||||
arguments: { base: 'USD', quote: 'EUR' },
|
||||
},
|
||||
}, sessionId);
|
||||
expect(called.ok()).toBe(true);
|
||||
const calledBody = await parseMcpResponse(called);
|
||||
expect(calledBody.error).toBeUndefined();
|
||||
expect(calledBody.result.isError).not.toBe(true);
|
||||
|
||||
// This is deliberately the real authoritative endpoint. The test never posts a
|
||||
// browser-only "complete" flag and never calls a test-only onboarding bypass.
|
||||
const progress = await browserJson(
|
||||
page,
|
||||
'GET',
|
||||
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/onboarding`,
|
||||
);
|
||||
expect(progress.status).toBe('complete');
|
||||
expect(progress.agent_id).toBe(provisioned.agentId);
|
||||
expect(progress.platform_api_key_id).toBe(provisioned.keyId);
|
||||
expect(progress.operation_id).toBe(provisioned.operationId);
|
||||
expect(progress.operation_version).toBe(provisioned.operationVersion);
|
||||
expect(progress.first_call.agent_id).toBe(provisioned.agentId);
|
||||
expect(progress.first_call.key_id).toBe(provisioned.keyId);
|
||||
expect(progress.first_call.operation_id).toBe(provisioned.operationId);
|
||||
expect(progress.first_call.operation_version).toBe(provisioned.operationVersion);
|
||||
expect(progress.first_call.tool_name).toBe(provisioned.toolName);
|
||||
expect(progress.first_call.request_id).toBeTruthy();
|
||||
expect(progress.first_call.trace_id).toMatch(/^[0-9a-f]{32}$/);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByTestId('onboarding-trigger')).toContainText(/7\s*\/\s*7/);
|
||||
await page.getByTestId('onboarding-trigger').click();
|
||||
const completion = page.getByTestId('onboarding-completion');
|
||||
await expect(completion).toContainText(provisioned.toolName);
|
||||
await expect(completion).toContainText(progress.first_call.occurred_at);
|
||||
await expect(completion).toContainText(progress.first_call.request_id);
|
||||
await expect(completion).toContainText(progress.first_call.trace_id);
|
||||
|
||||
await page.goto(`/logs?log_id=${encodeURIComponent(progress.first_call.log_id)}`);
|
||||
await expect(page.locator(`[data-id="${progress.first_call.log_id}"]`)).toBeVisible();
|
||||
await expect(page.locator(`[data-exp="${progress.first_call.log_id}"]`)).toContainText(progress.first_call.trace_id);
|
||||
await page.waitForFunction(() => Boolean(window.CrankAuth && window.CrankAuth.getCsrfToken()));
|
||||
|
||||
const revokeEvidence = await page.evaluate(async ({ workspaceId, agentId, keyId }) => {
|
||||
const before = await window.CrankApi.listAgentPlatformApiKeys(workspaceId, agentId);
|
||||
await window.CrankApi.revokeAgentPlatformApiKey(workspaceId, agentId, keyId);
|
||||
const after = await window.CrankApi.listAgentPlatformApiKeys(workspaceId, agentId);
|
||||
const onboarding = await window.CrankApi.getOnboarding(workspaceId);
|
||||
return {
|
||||
beforeCount: before.items.length,
|
||||
afterCount: after.items.length,
|
||||
revoked: after.items.find((item) => (item.api_key || item).id === keyId),
|
||||
onboarding,
|
||||
};
|
||||
}, { workspaceId: workspace.id, agentId: provisioned.agentId, keyId: provisioned.keyId });
|
||||
expect(revokeEvidence.afterCount).toBe(revokeEvidence.beforeCount);
|
||||
expect((revokeEvidence.revoked.api_key || revokeEvidence.revoked).status).toBe('revoked');
|
||||
expect(revokeEvidence.onboarding.status).not.toBe('complete');
|
||||
const keyStep = revokeEvidence.onboarding.steps.find((step) => step.id === 'key');
|
||||
expect(keyStep.completed).toBe(false);
|
||||
expect(keyStep.status).toBe('regressed');
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { getCurrentWorkspace, login, uniqueName } = require('./helpers');
|
||||
|
||||
test('Operation lifecycle keeps published versions immutable and exposes safe correlation', async ({ page }) => {
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
const name = uniqueName('immutable_lifecycle');
|
||||
|
||||
const evidence = await page.evaluate(async ({ workspaceId, operationName }) => {
|
||||
const schema = (field, description) => ({
|
||||
type: 'object',
|
||||
description,
|
||||
required: true,
|
||||
nullable: false,
|
||||
fields: {
|
||||
[field]: {
|
||||
type: 'string',
|
||||
description: `${field} value used by the lifecycle acceptance test`,
|
||||
required: true,
|
||||
nullable: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
const payload = {
|
||||
name: operationName,
|
||||
display_name: 'Immutable lifecycle acceptance Operation',
|
||||
category: 'quality',
|
||||
protocol: 'rest',
|
||||
security_level: 'standard',
|
||||
target: {
|
||||
kind: 'rest',
|
||||
base_url: 'https://httpbin.org',
|
||||
method: 'GET',
|
||||
path_template: '/anything',
|
||||
static_headers: {},
|
||||
},
|
||||
input_schema: schema('input', 'Lifecycle input contract'),
|
||||
output_schema: schema('output', 'Lifecycle output contract'),
|
||||
input_mapping: {
|
||||
rules: [{
|
||||
source: '$.mcp.input',
|
||||
target: '$.request.query.input',
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
output_mapping: {
|
||||
rules: [{
|
||||
source: '$.response.body.output',
|
||||
target: '$.output.output',
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
execution_config: { timeout_ms: 1000, headers: {} },
|
||||
tool_description: {
|
||||
title: 'Immutable lifecycle acceptance Operation',
|
||||
description: 'Exercises immutable Draft, Published Version, YAML, archive and correlation behavior.',
|
||||
tags: ['quality', 'lifecycle'],
|
||||
examples: [],
|
||||
},
|
||||
wizard_state: null,
|
||||
};
|
||||
|
||||
const created = await window.CrankApi.createOperation(workspaceId, payload);
|
||||
const operationId = created.operation_id;
|
||||
await window.CrankApi.getOperation(workspaceId, operationId);
|
||||
let testResult;
|
||||
try {
|
||||
testResult = await window.CrankApi.runOperationTest(workspaceId, operationId, {
|
||||
version: 1,
|
||||
input: { input: 'acceptance' },
|
||||
});
|
||||
} catch (error) {
|
||||
testResult = {
|
||||
tested_version: 1,
|
||||
request_id: error.requestId,
|
||||
trace_id: error.traceId,
|
||||
rejected_safely: true,
|
||||
};
|
||||
}
|
||||
const published = await window.CrankApi.publishOperation(workspaceId, operationId, 1);
|
||||
const publishedV1 = await window.CrankApi.getOperationVersion(workspaceId, operationId, 1);
|
||||
|
||||
await window.CrankApi.getOperation(workspaceId, operationId);
|
||||
const changedPayload = JSON.parse(JSON.stringify(payload));
|
||||
changedPayload.display_name = 'Changed Draft after publication';
|
||||
changedPayload.tool_description.title = 'Changed Draft after publication';
|
||||
const changed = await window.CrankApi.updateOperation(workspaceId, operationId, changedPayload);
|
||||
const publishedV1AfterEdit = await window.CrankApi.getOperationVersion(workspaceId, operationId, 1);
|
||||
|
||||
const yamlV1 = await window.CrankApi.exportOperation(workspaceId, operationId, { version: 1 });
|
||||
await window.CrankApi.getOperation(workspaceId, operationId);
|
||||
const imported = await window.CrankApi.importOperation(
|
||||
workspaceId,
|
||||
yamlV1,
|
||||
'upsert',
|
||||
operationId,
|
||||
);
|
||||
await window.CrankApi.getOperation(workspaceId, operationId);
|
||||
const archived = await window.CrankApi.archiveOperation(workspaceId, operationId);
|
||||
const publishedV1AfterArchive = await window.CrankApi.getOperationVersion(workspaceId, operationId, 1);
|
||||
|
||||
return {
|
||||
operationId,
|
||||
testResult,
|
||||
published,
|
||||
changed,
|
||||
imported,
|
||||
archived,
|
||||
originalSnapshot: publishedV1.snapshot,
|
||||
afterEditSnapshot: publishedV1AfterEdit.snapshot,
|
||||
afterArchiveSnapshot: publishedV1AfterArchive.snapshot,
|
||||
yamlV1,
|
||||
};
|
||||
}, { workspaceId: workspace.id, operationName: name });
|
||||
|
||||
expect(evidence.testResult.tested_version).toBe(1);
|
||||
expect(evidence.testResult.request_id).toMatch(/^[!-~]{1,128}$/);
|
||||
expect(evidence.testResult.trace_id).toMatch(/^[0-9a-f]{32}$/);
|
||||
expect(evidence.published.published_version).toBe(1);
|
||||
expect(evidence.changed.version).toBe(2);
|
||||
expect(evidence.imported.version).toBe(3);
|
||||
expect(evidence.archived.status).toBe('archived');
|
||||
expect(evidence.afterEditSnapshot).toEqual(evidence.originalSnapshot);
|
||||
expect(evidence.afterArchiveSnapshot).toEqual(evidence.originalSnapshot);
|
||||
expect(evidence.yamlV1).toContain("format_version: '2'");
|
||||
for (const forbidden of ['wizard_state:', 'created_at:', 'published_at:', 'operation_id:']) {
|
||||
expect(evidence.yamlV1).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
for (const locale of ['ru', 'en']) {
|
||||
test(`catalog lifecycle actions are state-aware in ${locale}`, async ({ page }) => {
|
||||
await login(page);
|
||||
await page.evaluate((language) => localStorage.setItem('crank_lang', language), locale);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
const name = uniqueName(`catalog_lifecycle_${locale}`);
|
||||
const operation = await page.evaluate(async ({ workspaceId, operationName, language }) => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
description: 'Catalog lifecycle UI contract',
|
||||
required: true,
|
||||
nullable: false,
|
||||
fields: {},
|
||||
};
|
||||
const created = await window.CrankApi.createOperation(workspaceId, {
|
||||
name: operationName,
|
||||
display_name: `Catalog lifecycle ${language}`,
|
||||
category: 'quality',
|
||||
protocol: 'rest',
|
||||
security_level: 'standard',
|
||||
target: { kind: 'rest', base_url: 'https://example.test', method: 'GET', path_template: '/', static_headers: {} },
|
||||
input_schema: schema,
|
||||
output_schema: schema,
|
||||
input_mapping: { rules: [] },
|
||||
output_mapping: { rules: [] },
|
||||
execution_config: { timeout_ms: 1000, headers: {} },
|
||||
tool_description: {
|
||||
title: `Catalog lifecycle ${language}`,
|
||||
description: 'A sufficiently detailed description for lifecycle UI acceptance.',
|
||||
tags: ['quality'],
|
||||
examples: [],
|
||||
},
|
||||
wizard_state: null,
|
||||
});
|
||||
await window.CrankApi.getOperation(workspaceId, created.operation_id);
|
||||
await window.CrankApi.publishOperation(workspaceId, created.operation_id, 1);
|
||||
return created;
|
||||
}, { workspaceId: workspace.id, operationName: name, language: locale });
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByPlaceholder(locale === 'ru' ? 'Поиск операций' : 'Search operations').fill(name);
|
||||
const row = page.locator('tbody tr').filter({ hasText: name });
|
||||
await expect(row).toHaveCount(1);
|
||||
await expect(row.locator('.row-btn-delete')).toBeHidden();
|
||||
await expect(row.locator('.row-btn-edit')).toBeVisible();
|
||||
await row.locator('.row-btn-edit').click();
|
||||
await expect(page).toHaveURL(new RegExp(`/wizard/\\?mode=edit&operationId=${operation.operation_id}`));
|
||||
await expect(page.locator('.wizard-shell')).toBeVisible();
|
||||
await expect(page.locator('#back-to-catalog')).toBeVisible();
|
||||
|
||||
await page.locator('#back-to-catalog').click();
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await page.getByPlaceholder(locale === 'ru' ? 'Поиск операций' : 'Search operations').fill(name);
|
||||
const refreshedRow = page.locator('tbody tr').filter({ hasText: name });
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await refreshedRow.locator('[data-testid="operation-archive"]').click();
|
||||
await expect(refreshedRow).toContainText(locale === 'ru' ? 'Неактивные' : 'Inactive');
|
||||
await expect(refreshedRow.locator('.row-btn-delete')).toBeHidden();
|
||||
});
|
||||
}
|
||||
@@ -6,7 +6,8 @@ test('operations page shows demo catalog and filter works', async ({ page }) =>
|
||||
await expect(page.locator('.page-heading')).toHaveText(localized('Operations', 'Операции'));
|
||||
await expect(page.locator('.ws-switcher-trigger')).toBeVisible();
|
||||
await expect(page.locator('#ws-dropdown')).toHaveCount(0);
|
||||
await expect(page.locator('tbody tr')).toHaveCount(1);
|
||||
await expect(page.getByText(localized('Loading operations', 'Загрузка операций'))).toBeHidden();
|
||||
expect(await page.locator('tbody tr').count()).toBeGreaterThanOrEqual(1);
|
||||
await page.getByPlaceholder(localized('Search operations', 'Поиск операций')).fill('frankfurter');
|
||||
await expect(page.locator('tbody tr')).toHaveCount(1);
|
||||
await expect(page.locator('tbody tr').first()).toContainText(/frankfurter_latest_rate/i);
|
||||
|
||||
@@ -84,3 +84,134 @@ test('generic json secret modal stays inside compact viewport', async ({ page })
|
||||
await expect(page.locator('[data-testid="secret-name-input"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="secret-submit-button"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('secret modal clears sensitive values and locks duplicate submit while saving', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/secrets');
|
||||
|
||||
await page.locator('[data-testid="secret-create-button"]').click();
|
||||
await page.locator('[data-testid="secret-name-input"]').fill(uniqueName('ui_secret_guard'));
|
||||
await page.locator('[data-testid="secret-value-input"]').fill('ui-secret-canary-close');
|
||||
await page.locator('#secret-modal-cancel-btn').click();
|
||||
await page.locator('[data-testid="secret-create-button"]').click();
|
||||
await expect(page.locator('[data-testid="secret-name-input"]')).toHaveValue('');
|
||||
await expect(page.locator('[data-testid="secret-value-input"]')).toHaveValue('');
|
||||
|
||||
let releaseCreate;
|
||||
const createReleased = new Promise((resolve) => {
|
||||
releaseCreate = resolve;
|
||||
});
|
||||
let createPosts = 0;
|
||||
await page.route(/\/api\/admin\/workspaces\/[^/]+\/secrets$/, async (route) => {
|
||||
if (route.request().method() !== 'POST') {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
createPosts += 1;
|
||||
await createReleased;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
id: 'secret_ui_guard',
|
||||
workspace_id: 'ws_test',
|
||||
name: 'ui_secret_guard',
|
||||
kind: 'token',
|
||||
status: 'active',
|
||||
current_version: 1,
|
||||
created_at: '2026-08-15T00:00:00Z',
|
||||
updated_at: '2026-08-15T00:00:00Z',
|
||||
last_used_at: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.locator('[data-testid="secret-name-input"]').fill(uniqueName('ui_secret_guard'));
|
||||
await page.locator('[data-testid="secret-value-input"]').fill('ui-secret-canary-submit');
|
||||
const submit = page.locator('[data-testid="secret-submit-button"]');
|
||||
await submit.click();
|
||||
await expect(submit).toBeDisabled();
|
||||
await submit.click({ force: true });
|
||||
expect(createPosts).toBe(1);
|
||||
releaseCreate();
|
||||
await expect(page.locator('[data-testid="secret-create-modal"]')).toBeHidden();
|
||||
expect(createPosts).toBe(1);
|
||||
});
|
||||
|
||||
test('stale secret rotation cannot close or report success for a newer modal context', async ({ page }) => {
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
const firstName = uniqueName('rotate_stale_first');
|
||||
const secondName = uniqueName('rotate_stale_second');
|
||||
const firstSecret = await browserJson(
|
||||
page,
|
||||
'POST',
|
||||
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/secrets`,
|
||||
{ name: firstName, kind: 'token', value: 'rotate-stale-first-value' },
|
||||
);
|
||||
await browserJson(
|
||||
page,
|
||||
'POST',
|
||||
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/secrets`,
|
||||
{ name: secondName, kind: 'token', value: 'rotate-stale-second-value' },
|
||||
);
|
||||
|
||||
let releaseRotate;
|
||||
const rotateReleased = new Promise((resolve) => {
|
||||
releaseRotate = resolve;
|
||||
});
|
||||
let secretListRequests = 0;
|
||||
await page.route(/\/api\/admin\/workspaces\/[^/]+\/secrets(?:\/[^/]+\/rotate)?$/, async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === 'GET') {
|
||||
secretListRequests += 1;
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
if (request.method() === 'POST' && /\/rotate$/.test(request.url())) {
|
||||
await rotateReleased;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
id: firstSecret.id,
|
||||
workspace_id: workspace.id,
|
||||
name: firstName,
|
||||
kind: 'token',
|
||||
status: 'active',
|
||||
current_version: 2,
|
||||
created_at: '2026-08-15T00:00:00Z',
|
||||
updated_at: '2026-08-15T00:00:00Z',
|
||||
last_used_at: null,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto('/secrets');
|
||||
const firstRow = page.locator('#secrets-tbody tr').filter({ hasText: firstName }).first();
|
||||
const secondRow = page.locator('#secrets-tbody tr').filter({ hasText: secondName }).first();
|
||||
await expect(firstRow).toBeVisible();
|
||||
await expect(secondRow).toBeVisible();
|
||||
const listRequestsBeforeRelease = secretListRequests;
|
||||
|
||||
await firstRow.getByTestId('secret-rotate-action').click();
|
||||
await page.locator('[data-testid="secret-value-input"]').fill('rotate-stale-first-new-value');
|
||||
await page.locator('[data-testid="secret-submit-button"]').click();
|
||||
await expect(page.locator('[data-testid="secret-submit-button"]')).toBeDisabled();
|
||||
await page.locator('#secret-modal-cancel-btn').click();
|
||||
await expect(page.getByTestId('secret-rotate-modal')).toBeHidden();
|
||||
|
||||
await secondRow.getByTestId('secret-rotate-action').click();
|
||||
await expect(page.getByTestId('secret-rotate-modal')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="secret-name-input"]')).toHaveValue(secondName);
|
||||
|
||||
releaseRotate();
|
||||
await page.waitForTimeout(150);
|
||||
await expect(page.getByTestId('secret-rotate-modal')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="secret-name-input"]')).toHaveValue(secondName);
|
||||
await expect(page.locator('.toast-success')).toHaveCount(0);
|
||||
expect(secretListRequests).toBe(listRequestsBeforeRelease);
|
||||
});
|
||||
|
||||
@@ -164,7 +164,6 @@ test('wizard builds visual request mappings from JSON sample and path params', a
|
||||
await page.evaluate(() => window.CrankWizardShell.goToStep(2));
|
||||
await expect(page.locator('#step-panel-2')).toBeVisible();
|
||||
await page.locator('#endpoint-path').fill('/rates/{date}');
|
||||
|
||||
await page.evaluate(() => window.CrankWizardShell.goToStep(3));
|
||||
await expect(page.locator('#step-panel-3-rest')).toBeVisible();
|
||||
await page.locator('.method-card[data-method="GET"]').click();
|
||||
@@ -356,8 +355,6 @@ test('wizard edit mode hydrates fields from operation version snapshot', async (
|
||||
retry_policy: { max_attempts: 2 },
|
||||
auth_profile_ref: null,
|
||||
headers: {},
|
||||
protocol_options: null,
|
||||
streaming: null,
|
||||
},
|
||||
tool_description: {
|
||||
title: 'Получить историю курсов за месяц',
|
||||
@@ -571,8 +568,6 @@ test('wizard shows agent-facing MCP preview from current draft fields', async ({
|
||||
retry_policy: null,
|
||||
auth_profile_ref: null,
|
||||
headers: {},
|
||||
protocol_options: null,
|
||||
streaming: null,
|
||||
approval_policy: {
|
||||
required: true,
|
||||
risk_level: 'financial',
|
||||
@@ -655,6 +650,7 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
||||
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/operations/${operationId}`,
|
||||
async (route) => {
|
||||
if (route.request().method() === 'PATCH') {
|
||||
expect(route.request().headers()['if-match']).toBe('"operation-etag-v3"');
|
||||
updatePayload = route.request().postDataJSON();
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
@@ -671,6 +667,7 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
||||
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
headers: { ETag: '"operation-etag-v3"' },
|
||||
body: JSON.stringify({
|
||||
id: operationId,
|
||||
workspace_id: workspace.id,
|
||||
@@ -793,8 +790,6 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
||||
retry_policy: null,
|
||||
auth_profile_ref: null,
|
||||
headers: {},
|
||||
protocol_options: null,
|
||||
streaming: null,
|
||||
approval_policy: {
|
||||
required: true,
|
||||
risk_level: 'financial',
|
||||
@@ -877,8 +872,6 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
||||
retry_policy: null,
|
||||
auth_profile_ref: null,
|
||||
headers: {},
|
||||
protocol_options: null,
|
||||
streaming: null,
|
||||
approval_policy: {
|
||||
required: true,
|
||||
mode: 'custom',
|
||||
|
||||
Reference in New Issue
Block a user