Files
LinkPort/web/tests/target-auth.spec.ts

309 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
}
});