308 lines
13 KiB
JavaScript
308 lines
13 KiB
JavaScript
const { test, expect } = require('@playwright/test');
|
|
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, {
|
|
slug: uniqueName('playwright_keys_agent'),
|
|
display_name: 'Playwright Keys Agent',
|
|
description: 'Agent for API key page smoke test.',
|
|
instructions: {},
|
|
tool_selection_policy: {},
|
|
});
|
|
await page.goto('/api-keys');
|
|
await expect(page.locator('.page-title')).toHaveText(localized('Agent Keys', 'Ключи агентов'));
|
|
await expect(page.locator('[data-testid="machine-access-summary"]')).toHaveCount(0);
|
|
await expect(page.locator('#machine-access-note')).toHaveCount(0);
|
|
await expect(page.locator('[data-i18n="apikeys.agent.subtitle"]')).toContainText(
|
|
localized('Select the AI agent this key is issued for', 'Выберите AI-агента')
|
|
);
|
|
await expect(page.locator('#agent-select')).not.toBeDisabled();
|
|
await expect(page.locator('#btn-create-key')).toBeEnabled();
|
|
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(
|
|
localized('human confirmation interface', 'человек подтверждает действие')
|
|
);
|
|
await expect(page.locator('#btn-create-key')).toContainText(
|
|
localized('Create approval key', 'Создать ключ подтверждения')
|
|
);
|
|
await page.locator('#btn-create-key').click();
|
|
await expect(page.locator('.modal-title')).toHaveText(localized('Create approval key', 'Создать ключ подтверждения'));
|
|
await expect(page.locator('#approval-key-warning')).toContainText(
|
|
localized('Do not pass this key to an LLM', 'Не передавайте этот ключ LLM')
|
|
);
|
|
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);
|
|
});
|