309 lines
22 KiB
TypeScript
309 lines
22 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
||
import { targetDraftPayload } from '../src/domain/targetDraft';
|
||
import { validateTarget, validateGateway } from '../src/domain/formValidation';
|
||
import { changeTargetAuth, targetAccountRequired } from '../src/domain/targetAuth';
|
||
|
||
test('无认证与空密码草稿不混淆保留、清除语义,也不放宽网关校验', () => {
|
||
const account: any = { id: 'chosen', remark: 'Fixture', username: 'fixture', password: '', authMethod: 'password' };
|
||
const draft: any = { name: 'Fixture', type: 'mysql', targetHost: 'fixture.invalid', targetPort: 3306, accounts: [account] };
|
||
expect(validateTarget(draft)).toHaveLength(0);
|
||
expect(targetDraftPayload(draft).accounts[0]).not.toHaveProperty('password');
|
||
account.clearPassword = true;
|
||
expect(targetDraftPayload(draft).accounts[0].password).toBe('');
|
||
expect(targetDraftPayload(draft).accounts[0]).not.toHaveProperty('clearPassword');
|
||
account.authMethod = 'none';
|
||
account.password = 'old-secret';
|
||
account.privateKey = 'old-key';
|
||
account.privateKeyAssetId = 'old-asset';
|
||
account.certificateAssetId = 'old-certificate';
|
||
account.passphrase = 'old-phrase';
|
||
const anonymous = targetDraftPayload(draft).accounts[0];
|
||
for (const key of ['username', 'password', 'privateKey', 'privateKeyAssetId', 'certificate', 'certificateAssetId', 'passphrase']) expect(anonymous[key]).toBe('');
|
||
expect(validateTarget(draft).some(issue => issue.path.endsWith('.authMethod'))).toBeTruthy();
|
||
for (const protocol of ['mongodb', 'redis', 'http', 'https']) expect(validateTarget({ ...draft, type: protocol })).toHaveLength(0);
|
||
changeTargetAuth(account);
|
||
expect(account.password).toBe('');
|
||
expect(account.username).toBe('');
|
||
expect(account.clearPassword).toBe(false);
|
||
const accountlessRedis = { ...draft, type: 'redis', accounts: [] };
|
||
expect(targetAccountRequired('redis', accountlessRedis.accounts)).toBe(false);
|
||
expect(validateTarget(accountlessRedis)).toHaveLength(0);
|
||
expect(targetDraftPayload(accountlessRedis).accounts).toEqual([]);
|
||
for (const protocol of ['mysql', 'mongodb', 'http', 'https']) expect(validateTarget({ ...accountlessRedis, type: protocol }).some(issue => issue.path === 'accounts')).toBeTruthy();
|
||
expect(validateGateway({ name: 'Gateway', type: 'mongodb', listenHost: '127.0.0.1', listenPort: 27018, gatewayAccounts: [{ id: 'client', username: 'client', password: '' }] }).some(issue => issue.path.endsWith('.password'))).toBeTruthy();
|
||
});
|
||
|
||
test('Redis 可不创建账号直接测试、保存、回读和识别集群', async ({ page }) => {
|
||
page.setDefaultTimeout(10000);
|
||
await page.goto('/');
|
||
const session = await (await page.request.get('/api/v1/session')).json();
|
||
const headers = { 'X-CSRF-Token': session.csrfToken };
|
||
let targetId = '';
|
||
try {
|
||
await page.getByRole('navigation').getByRole('button', { name: /目标服务/ }).click();
|
||
await page.getByRole('button', { name: '+ 新建目标', exact: true }).click();
|
||
const editor = page.getByRole('dialog', { name: '新建目标服务', exact: true });
|
||
await editor.locator('.el-radio-button').filter({ has: page.getByRole('radio', { name: 'Redis', exact: true }) }).click();
|
||
await editor.getByPlaceholder('例如:生产数据库 · 只读分析').fill('Redis 零账号回归');
|
||
await editor.locator('[data-field="targetHost"] input').fill('fixture.invalid');
|
||
await editor.locator('.el-select').filter({ has: page.getByRole('combobox', { name: '部署模式', exact: true }) }).click();
|
||
await page.getByRole('option', { name: '单节点', exact: true }).click();
|
||
await editor.getByRole('tab', { name: '账号', exact: true }).click();
|
||
await expect(editor.locator('.target-accounts .account-fields')).toHaveCount(0);
|
||
await expect(editor.locator('.target-accounts')).toContainText('当前为无账号模式,不向 Redis 服务发送 AUTH 命令;网关登录认证仍为必填并保持不变;添加账号后,使用该目标时必须选择目标账号。');
|
||
await expect(editor.getByRole('combobox', { name: '编辑目标账号', exact: true })).toBeDisabled();
|
||
await expect(editor.getByRole('combobox', { name: '连接测试账号', exact: true })).toHaveCount(0);
|
||
await page.route('**/api/v1/targets/test', route => route.fulfill({ json: { version: 'accountless-redis-fixture', tls: false } }));
|
||
const testing = page.waitForRequest(request => request.url().endsWith('/api/v1/targets/test'));
|
||
await editor.getByRole('button', { name: '测试连接', exact: true }).click();
|
||
const tested = (await testing).postDataJSON();
|
||
expect(tested.accountId).toBe('');
|
||
expect(tested.config.accounts).toEqual([]);
|
||
const result = page.getByRole('dialog', { name: '连接测试', exact: true });
|
||
await expect(result).toContainText('accountless-redis-fixture');
|
||
await result.getByRole('button', { name: '关闭', exact: true }).click();
|
||
const saving = page.waitForResponse(response => response.url().endsWith('/api/v1/targets') && response.request().method() === 'POST');
|
||
await editor.getByRole('button', { name: '保存配置', exact: true }).click();
|
||
const response = await saving;
|
||
expect(response.ok(), await response.text()).toBeTruthy();
|
||
const saved = await response.json();
|
||
targetId = saved.config.id;
|
||
expect(saved.config.accounts || []).toEqual([]);
|
||
await expect(editor).not.toBeVisible();
|
||
await page.reload();
|
||
await page.getByRole('navigation').getByRole('button', { name: /目标服务/ }).click();
|
||
const card = page.locator('.target-card').filter({ hasText: 'Redis 零账号回归' });
|
||
await expect(card).toContainText('无认证');
|
||
await expect(card.getByRole('combobox', { name: '测试及查看账号', exact: true })).toHaveCount(0);
|
||
await expect(card.getByRole('button', { name: '查看密码', exact: true })).toHaveCount(0);
|
||
await card.getByRole('button', { name: '配置目标', exact: true }).click();
|
||
const editing = page.getByRole('dialog', { name: '编辑目标服务', exact: true });
|
||
await editing.getByRole('tab', { name: '账号', exact: true }).click();
|
||
await expect(editing.locator('.target-accounts .account-fields')).toHaveCount(0);
|
||
await expect(editing.locator('.target-accounts')).toContainText('当前为无账号模式,不向 Redis 服务发送 AUTH 命令;网关登录认证仍为必填并保持不变;添加账号后,使用该目标时必须选择目标账号。');
|
||
await editing.getByRole('tab', { name: '连接信息', exact: true }).click();
|
||
await page.route(`**/api/v1/targets/${targetId}/detect-cluster`, route => route.fulfill({ json: { type: 'standalone', selected: { host: 'fixture.invalid', port: 6379, role: 'standalone' }, nodes: [] } }));
|
||
const detecting = page.waitForRequest(request => request.url().endsWith(`/api/v1/targets/${targetId}/detect-cluster`));
|
||
const detect = editing.getByRole('button', { name: '识别集群', exact: true });
|
||
await expect(detect).toBeEnabled();
|
||
await detect.click();
|
||
expect((await detecting).url()).not.toContain('accountId');
|
||
await expect(editing.locator('.cluster-result')).toContainText('单节点');
|
||
await editing.locator('.workspace-page__footer').getByRole('button', { name: '取消', exact: true }).click();
|
||
await page.getByRole('navigation').getByRole('button', { name: '监控运维', exact: true }).click();
|
||
const targetSelect = page.locator('.el-select').filter({ has: page.getByPlaceholder('目标服务', { exact: true }) });
|
||
await targetSelect.click();
|
||
await page.getByRole('option', { name: 'Redis 零账号回归', exact: true }).click();
|
||
await expect(page.getByPlaceholder('目标账号', { exact: true })).toHaveCount(0);
|
||
const check = page.getByRole('button', { name: '检查连接', exact: true });
|
||
await expect(check).toBeEnabled();
|
||
await page.route('**/api/v1/monitoring/check', route => route.fulfill({ json: { healthy: true, protocol: 'redis' } }));
|
||
const checking = page.waitForRequest(request => request.url().endsWith('/api/v1/monitoring/check'));
|
||
await check.click();
|
||
expect((await checking).postDataJSON()).not.toHaveProperty('accountId');
|
||
} finally {
|
||
await page.unrouteAll({ behavior: 'wait' });
|
||
if (targetId) await page.request.delete(`/api/v1/targets/${targetId}`, { headers });
|
||
}
|
||
});
|
||
|
||
test('Redis 零账号目标可通过画板关联并用于网关测试和目标切换', async ({ page }) => {
|
||
await page.goto('/');
|
||
const session = await (await page.request.get('/api/v1/session')).json();
|
||
const headers = { 'X-CSRF-Token': session.csrfToken };
|
||
let targetId = '', gatewayId = '';
|
||
try {
|
||
const target = await page.request.post('/api/v1/targets', { headers, data: {
|
||
name: 'Redis 零账号画板目标', type: 'redis', targetHost: 'fixture.invalid', targetPort: 6379,
|
||
accounts: [], cluster: { mode: 'single', nodes: [] },
|
||
} });
|
||
expect(target.ok(), await target.text()).toBeTruthy();
|
||
targetId = (await target.json()).config.id;
|
||
const gateway = await page.request.post('/api/v1/proxies', { headers, data: {
|
||
name: 'Redis 零账号画板网关', type: 'redis', listenHost: '127.0.0.1', listenPort: 26479,
|
||
gatewayAccounts: [{ id: 'client', username: 'client', password: 'fixture-client' }],
|
||
} });
|
||
expect(gateway.ok(), await gateway.text()).toBeTruthy();
|
||
gatewayId = (await gateway.json()).config.id;
|
||
await page.reload();
|
||
await page.getByRole('navigation').getByRole('button', { name: '拖拽连线', exact: true }).click();
|
||
const source = page.getByRole('button', { name: '连接网关账号 Redis 零账号画板网关 client', exact: true });
|
||
const destination = page.locator(`[data-node-id="target:${targetId}"] .target-account`);
|
||
await source.click();
|
||
await expect(destination).toBeEnabled();
|
||
const connecting = page.waitForResponse(response => response.url().endsWith(`/api/v1/proxies/${gatewayId}/target`) && response.request().method() === 'POST');
|
||
await destination.click();
|
||
const response = await connecting;
|
||
expect(response.ok(), await response.text()).toBeTruthy();
|
||
expect(response.request().postDataJSON()).toMatchObject({ targetId, targetAccountId: '', gatewayAccountId: 'client' });
|
||
const read = async () => (await (await page.request.get(`/api/v1/proxies/${gatewayId}`)).json());
|
||
await expect.poll(async () => (await read()).config.gatewayAccounts[0].targetId).toBe(targetId);
|
||
expect((await read()).config.gatewayAccounts[0].targetAccountId || '').toBe('');
|
||
const edge = page.locator(`.connection-lines path[data-gateway-account-id="client"][data-target-id="${targetId}"]`);
|
||
await expect(edge).toHaveAttribute('data-target-account-id', '');
|
||
await expect(page.locator('.mapping-details')).toContainText('无认证');
|
||
|
||
await page.route(`**/api/v1/proxies/${gatewayId}/test**`, route => route.fulfill({ json: { version: 'accountless-gateway-fixture', tls: false } }));
|
||
const testing = page.waitForRequest(request => request.url().includes(`/api/v1/proxies/${gatewayId}/test`));
|
||
await page.getByRole('button', { name: '测试网关连接 Redis 零账号画板网关', exact: true }).click();
|
||
await testing;
|
||
const result = page.getByRole('dialog', { name: '连接测试', exact: true });
|
||
await expect(result).toContainText('accountless-gateway-fixture');
|
||
await expect(result).toContainText('无认证');
|
||
await result.getByRole('button', { name: '关闭', exact: true }).click();
|
||
|
||
await page.getByRole('navigation').getByRole('button', { name: '网关服务', exact: true }).click();
|
||
await page.getByRole('row').filter({ hasText: 'Redis 零账号画板网关' }).getByRole('button', { name: '切换目标', exact: true }).click();
|
||
const switching = page.getByRole('dialog', { name: '切换目标服务', exact: true });
|
||
const accountSelect = switching.locator('.el-select').filter({ has: page.getByRole('combobox', { name: '切换网关账号', exact: true }) });
|
||
await accountSelect.click();
|
||
await page.getByRole('option', { name: 'client', exact: true }).click();
|
||
await expect(switching.getByRole('combobox', { name: '切换目标账号', exact: true })).toHaveCount(0);
|
||
await expect(switching.getByRole('button', { name: '保存并切换', exact: true })).toBeEnabled();
|
||
await switching.getByRole('button', { name: '取消', exact: true }).last().click();
|
||
} finally {
|
||
await page.unrouteAll({ behavior: 'wait' });
|
||
if (gatewayId) await page.request.delete(`/api/v1/proxies/${gatewayId}`, { headers });
|
||
if (targetId) await page.request.delete(`/api/v1/targets/${targetId}`, { headers });
|
||
}
|
||
});
|
||
|
||
for (const [protocol, label] of [['mongodb', 'MongoDB'], ['http', 'HTTP'], ['https', 'HTTPS']]) {
|
||
test(`${label} 可新建无认证目标,测试与保存一致并回读`, async ({ page }) => {
|
||
page.setDefaultTimeout(10000);
|
||
await page.goto('/');
|
||
const session = await (await page.request.get('/api/v1/session')).json();
|
||
const headers = { 'X-CSRF-Token': session.csrfToken };
|
||
let targetId = '';
|
||
try {
|
||
await page.getByRole('navigation').getByRole('button', { name: /目标服务/ }).click();
|
||
await page.getByRole('button', { name: '+ 新建目标', exact: true }).click();
|
||
const editor = page.getByRole('dialog', { name: '新建目标服务', exact: true });
|
||
await editor.locator('.el-radio-button').filter({ has: page.getByRole('radio', { name: label, exact: true }) }).click();
|
||
await editor.getByPlaceholder('例如:生产数据库 · 只读分析').fill(`${label} 无认证回归`);
|
||
await editor.locator('[data-field="targetHost"] input').fill('fixture.invalid');
|
||
if (['mongodb', 'redis'].includes(protocol)) {
|
||
await editor.locator('.el-select').filter({ has: page.getByRole('combobox', { name: '部署模式', exact: true }) }).click();
|
||
await page.getByRole('option', { name: '单节点', exact: true }).click();
|
||
}
|
||
await editor.getByRole('tab', { name: '账号', exact: true }).click();
|
||
await editor.locator('.el-radio-button').filter({ has: page.getByRole('radio', { name: '无认证', exact: true }) }).click();
|
||
await expect(editor.getByRole('textbox', { name: '登录用户名', exact: true })).toHaveCount(0);
|
||
await expect(editor.getByLabel('账号密码', { exact: true })).toHaveCount(0);
|
||
await page.route('**/api/v1/targets/test', route => route.fulfill({ json: { version: 'anonymous-fixture', tls: false } }));
|
||
const testing = page.waitForRequest(request => request.url().endsWith('/api/v1/targets/test'));
|
||
await editor.getByRole('button', { name: '测试连接', exact: true }).click();
|
||
const tested = (await testing).postDataJSON();
|
||
expect(tested.config.accounts[0]).toMatchObject({ authMethod: 'none', username: '', password: '' });
|
||
const result = page.getByRole('dialog', { name: '连接测试', exact: true });
|
||
await expect(result).toContainText('anonymous-fixture');
|
||
await result.getByRole('button', { name: '关闭', exact: true }).click();
|
||
const saving = page.waitForResponse(response => response.url().endsWith('/api/v1/targets') && response.request().method() === 'POST');
|
||
await editor.getByRole('button', { name: '保存配置', exact: true }).click();
|
||
const response = await saving;
|
||
expect(response.ok(), await response.text()).toBeTruthy();
|
||
const saved = await response.json();
|
||
targetId = saved.config.id;
|
||
expect(saved.config.accounts[0]).toMatchObject({ authMethod: 'none', username: '' });
|
||
expect(saved.secretStatus[`account:${saved.config.accounts[0].id}:password`]).toBe(false);
|
||
await expect(editor).not.toBeVisible();
|
||
await page.reload();
|
||
await page.getByRole('navigation').getByRole('button', { name: /目标服务/ }).click();
|
||
await page.locator('.target-card').filter({ hasText: `${label} 无认证回归` }).getByRole('button', { name: '配置目标', exact: true }).click();
|
||
const editing = page.getByRole('dialog', { name: '编辑目标服务', exact: true });
|
||
await editing.getByRole('tab', { name: '账号', exact: true }).click();
|
||
await expect(editing.getByRole('radio', { name: '无认证', exact: true })).toBeChecked();
|
||
if (protocol === 'mongodb') {
|
||
await editing.getByRole('tab', { name: '连接信息', exact: true }).click();
|
||
await editing.getByLabel('MongoDB 连接串', { exact: true }).fill('mongodb://fixture:imported-password@fixture.invalid:27017/?directConnection=true');
|
||
await editing.getByRole('button', { name: '解析并填入配置', exact: true }).click();
|
||
await editing.getByRole('tab', { name: '账号', exact: true }).click();
|
||
await expect(editing.getByRole('radio', { name: '密码', exact: true })).toBeChecked();
|
||
await expect(editing.getByLabel('账号密码', { exact: true })).toHaveValue('imported-password');
|
||
}
|
||
} finally {
|
||
await page.unrouteAll({ behavior: 'wait' });
|
||
if (targetId) await page.request.delete(`/api/v1/targets/${targetId}`, { headers });
|
||
}
|
||
});
|
||
}
|
||
|
||
test('旧 Mongo 账号默认密码模式,切换无认证清除已有凭据且支持小屏', async ({ page }) => {
|
||
page.setDefaultTimeout(10000);
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await page.goto('/');
|
||
const session = await (await page.request.get('/api/v1/session')).json();
|
||
const headers = { 'X-CSRF-Token': session.csrfToken };
|
||
let targetId = '';
|
||
try {
|
||
const created = await page.request.post('/api/v1/targets', { headers, data: {
|
||
name: '切换无认证回归', type: 'mongodb', targetHost: 'fixture.invalid', targetPort: 27017,
|
||
accounts: [{ id: 'chosen', remark: 'Fixture', username: 'fixture', password: 'saved-secret' }],
|
||
} });
|
||
expect(created.ok()).toBeTruthy();
|
||
targetId = (await created.json()).config.id;
|
||
await page.reload();
|
||
await page.getByRole('navigation').getByRole('button', { name: /目标服务/ }).click();
|
||
await page.locator('.target-card').filter({ hasText: '切换无认证回归' }).getByRole('button', { name: '配置目标', exact: true }).click();
|
||
const editor = page.getByRole('dialog', { name: '编辑目标服务', exact: true });
|
||
await editor.getByRole('tab', { name: '账号', exact: true }).click();
|
||
await expect(editor.getByRole('radio', { name: '密码', exact: true })).toBeChecked();
|
||
await editor.locator('.el-radio-button').filter({ has: page.getByRole('radio', { name: '无认证', exact: true }) }).click();
|
||
await expect(editor.getByRole('textbox', { name: '登录用户名', exact: true })).toHaveCount(0);
|
||
await expect.poll(() => editor.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
|
||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
|
||
await page.screenshot({ path: 'test-results/target-no-auth-mobile.png' });
|
||
const saving = page.waitForResponse(response => response.url().endsWith(`/api/v1/targets/${targetId}`) && response.request().method() === 'PUT');
|
||
await editor.getByRole('button', { name: '保存配置', exact: true }).click();
|
||
expect((await saving).ok()).toBeTruthy();
|
||
const saved = await (await page.request.get(`/api/v1/targets/${targetId}`)).json();
|
||
expect(saved.secretStatus['account:chosen:password']).toBe(false);
|
||
expect(saved.config.accounts[0]).toMatchObject({ username: '', authMethod: 'none' });
|
||
} finally {
|
||
if (targetId) await page.request.delete(`/api/v1/targets/${targetId}`, { headers });
|
||
}
|
||
});
|
||
|
||
test('编辑已有账号留空保留密码,勾选空密码才在测试和保存时清除', async ({ page }) => {
|
||
page.setDefaultTimeout(10000);
|
||
await page.goto('/');
|
||
const session = await (await page.request.get('/api/v1/session')).json();
|
||
const headers = { 'X-CSRF-Token': session.csrfToken };
|
||
let targetId = '';
|
||
try {
|
||
const response = await page.request.post('/api/v1/targets', { headers, data: {
|
||
name: '空密码回归', type: 'mysql', targetHost: 'fixture.invalid', targetPort: 3306,
|
||
accounts: [{ id: 'chosen', remark: 'Fixture', username: 'fixture', password: 'saved-secret', authMethod: 'password' }],
|
||
} });
|
||
expect(response.ok()).toBeTruthy();
|
||
targetId = (await response.json()).config.id;
|
||
await page.reload();
|
||
await page.getByRole('navigation').getByRole('button', { name: /目标服务/ }).click();
|
||
await page.locator('.target-card').filter({ hasText: '空密码回归' }).getByRole('button', { name: '配置目标', exact: true }).click();
|
||
const editor = page.getByRole('dialog', { name: '编辑目标服务', exact: true });
|
||
await editor.getByRole('tab', { name: '账号', exact: true }).click();
|
||
await expect(editor.getByLabel('账号密码', { exact: true })).toHaveAttribute('placeholder', '留空保留已保存密码');
|
||
await page.route('**/api/v1/targets/test', route => route.fulfill({ json: { version: 'empty-password-fixture', tls: false } }));
|
||
for (const clear of [false, true]) {
|
||
if (clear) await editor.locator('.el-checkbox').filter({ has: page.getByRole('checkbox', { name: '使用空密码(清除已保存密码)', exact: true }) }).click();
|
||
const testing = page.waitForRequest(request => request.url().endsWith('/api/v1/targets/test'));
|
||
await editor.getByRole('button', { name: '测试连接', exact: true }).click();
|
||
const account = (await testing).postDataJSON().config.accounts[0];
|
||
if (clear) expect(account.password).toBe('');
|
||
else expect(account).not.toHaveProperty('password');
|
||
await page.getByRole('dialog', { name: '连接测试', exact: true }).getByRole('button', { name: '关闭', exact: true }).click();
|
||
}
|
||
const before = await (await page.request.get(`/api/v1/targets/${targetId}`)).json();
|
||
expect(before.secretStatus['account:chosen:password']).toBe(true);
|
||
const saving = page.waitForResponse(response => response.url().endsWith(`/api/v1/targets/${targetId}`) && response.request().method() === 'PUT');
|
||
await editor.getByRole('button', { name: '保存配置', exact: true }).click();
|
||
expect((await saving).ok()).toBeTruthy();
|
||
const after = await (await page.request.get(`/api/v1/targets/${targetId}`)).json();
|
||
expect(after.secretStatus['account:chosen:password']).toBe(false);
|
||
expect(after.config.accounts[0].username).toBe('fixture');
|
||
} finally {
|
||
await page.unrouteAll({ behavior: 'wait' });
|
||
if (targetId) await page.request.delete(`/api/v1/targets/${targetId}`, { headers });
|
||
}
|
||
});
|