282 lines
19 KiB
TypeScript
282 lines
19 KiB
TypeScript
import { test, expect, Page } from "@playwright/test";
|
||
|
||
test('拖拽网关保存目标关联,审计筛选和导出', async ({page})=>{
|
||
await page.goto('/');
|
||
// Scope the drag board to this test so both endpoints stay in the viewport.
|
||
await page.getByRole('combobox', {name: '筛选项目', exact: true}).fill('拖拽审计独立项目');
|
||
await page.getByRole('option', {name: '拖拽审计独立项目', exact: true}).click();
|
||
await target(page,'MySQL','拖拽原目标');
|
||
await gateway(page,'MySQL','拖拽原目标','拖拽网关');
|
||
await target(page,'MySQL','拖拽新目标');
|
||
await page.getByRole('button',{name:/拖拽连线/}).click();
|
||
await page.getByRole('button',{name:'连接网关账号 拖拽网关 gateway',exact:true}).dragTo(page.getByRole('button',{name:'连接目标账号 拖拽新目标 普通账号',exact:true}));
|
||
await expect(page.getByText('关联已保存')).toBeVisible();
|
||
await page.reload();
|
||
const allTargets=await(await page.request.get('/api/v1/targets')).json();
|
||
const allGateways=await(await page.request.get('/api/v1/proxies')).json();
|
||
expect(allGateways.find((g:any)=>g.config.name==='拖拽网关').config.gatewayAccounts[0].targetId).toBe(allTargets.find((t:any)=>t.config.name==='拖拽新目标').config.id);
|
||
await page.getByRole('button',{name:/命令审计/}).click();
|
||
await page.getByPlaceholder('来源 IP(精确匹配)').fill('127.0.0.1');
|
||
await page.getByPlaceholder('命令关键字').fill('SELECT');
|
||
const request=page.waitForRequest(r=>r.url().includes('/api/v1/commands?')&&r.url().includes('keyword=SELECT'));
|
||
await page.getByRole('button',{name:'查询',exact:true}).click();
|
||
expect((await request).url()).toContain('source=127.0.0.1');
|
||
const download=page.waitForEvent('download');
|
||
await page.getByRole('button',{name:'导出 CSV'}).click();
|
||
expect((await download).suggestedFilename()).toBe('linkport-commands.csv');
|
||
await page.getByRole('button',{name:'重置',exact:true}).click();
|
||
await expect(page.getByPlaceholder('命令关键字')).toHaveValue('');
|
||
});
|
||
|
||
async function target(page: Page, type: string, name: string, outbound: string | boolean = false) {
|
||
if (outbound === true) {
|
||
const profileName = name + ' SOCKS5';
|
||
await createOutbound(page, profileName, 'socks5');
|
||
outbound = profileName;
|
||
}
|
||
await page.getByRole('button', { name: /目标服务/ }).first().click();
|
||
await page.getByRole('button', { name: '+ 新建目标' }).click();
|
||
const drawer = page.getByRole('dialog');
|
||
await drawer.locator('.el-radio-button').filter({ hasText: new RegExp(`^${type}$`) }).click();
|
||
await drawer.getByPlaceholder('例如:生产数据库 · 只读分析').fill(name);
|
||
await drawer.getByPlaceholder('数据库或服务器域名 / IP').fill('127.0.0.1');
|
||
await expect(drawer.getByRole('switch', { name: '只读模式' })).toHaveCount(0);
|
||
if (['MySQL','MongoDB'].includes(type)) {
|
||
await drawer.getByRole('tab', { name: '高级', exact: true }).click();
|
||
await expect(drawer.getByRole('switch', { name: '目标连接压缩' })).not.toBeChecked();
|
||
await drawer.locator('.setting-row').filter({ hasText: '目标连接压缩' }).locator('.el-switch__core').click();
|
||
} else await expect(drawer.getByRole('switch', { name: '目标连接压缩' })).toHaveCount(0);
|
||
if (typeof outbound === 'string') {
|
||
await drawer.getByRole('tab', { name: '连接方式', exact: true }).click();
|
||
await drawer.getByRole('radiogroup', { name: '连接方式', exact: true }).getByRole('radio', { name: outbound.includes('SSH') ? 'SSH' : 'SOCKS5', exact: true }).locator('..').click();
|
||
await drawer.getByRole('combobox', { name: '代理' }).click();
|
||
await page.getByRole('option', { name: outbound, exact: true }).click();
|
||
}
|
||
await drawer.getByRole('tab', { name: '账号', exact: true }).click();
|
||
if (type === 'Redis') await drawer.getByRole('button', { name: '添加账号', exact: true }).click();
|
||
await drawer.getByRole('textbox', { name: '账号备注', exact: true }).fill('普通账号');
|
||
await drawer.getByRole('textbox', { name: '登录用户名', exact: true }).fill('upstream');
|
||
await drawer.getByRole('textbox', { name: '账号密码', exact: true }).fill('target-test-password');
|
||
await drawer.getByRole('button', { name: '保存配置' }).click();
|
||
await expect(drawer).not.toBeVisible();
|
||
await expect(page.locator('.target-card').filter({ hasText: name })).toBeVisible();
|
||
}
|
||
|
||
async function createOutbound(page: Page, name: string, mode: 'socks5'|'ssh') {
|
||
await page.getByRole('navigation').getByRole('button', { name: /代理管理/ }).click();
|
||
await page.getByRole('button', { name: '新建代理' }).click();
|
||
const d = page.getByRole('dialog');
|
||
await d.locator('.el-form-item').filter({ hasText: /^代理名称/ }).getByRole('textbox').fill(name);
|
||
await d.locator('.el-radio-button').filter({ hasText: mode === 'ssh' ? /^SSH$/ : /^SOCKS5$/ }).click();
|
||
await d.locator('.el-form-item').filter({ hasText: /主机$/ }).getByRole('textbox').fill('127.0.0.1');
|
||
if (mode === 'socks5') await d.locator('.el-radio').filter({ hasText: '用户名与密码' }).click();
|
||
await d.locator('.el-form-item').filter({ hasText: /用户名$/ }).getByRole('textbox').fill(mode === 'ssh' ? 'jump' : 'socks');
|
||
await d.getByRole('textbox', { name: new RegExp(`${mode === 'ssh' ? 'SSH' : 'SOCKS5'} 密码`) }).fill(mode === 'ssh' ? 'jump-test-password' : 'socks-test-password');
|
||
if (mode === 'ssh') {
|
||
// This fixture checks references and credential retention, not host trust.
|
||
await d.locator('[data-field="outbound.skipHostKeyVerify"] .el-checkbox').click();
|
||
await expect(d.getByRole('checkbox', { name: /跳过主机指纹校验/ })).toBeChecked();
|
||
}
|
||
await d.getByRole('button', { name: '保存代理' }).click();
|
||
await expect(d).not.toBeVisible();
|
||
await page.getByRole('button', { name: /目标服务/ }).first().click();
|
||
}
|
||
async function gateway(page: Page, type: string, targetName: string, name: string) {
|
||
await page.getByRole('button', { name: /网关服务/ }).first().click();
|
||
await page.getByRole('button', { name: '+ 新建网关' }).click();
|
||
const drawer = page.getByRole('dialog');
|
||
await drawer.locator('.el-radio-button').filter({ hasText: new RegExp(`^${type}$`) }).click();
|
||
await drawer.getByPlaceholder('例如:生产数据库 · 只读分析').fill(name);
|
||
await expect(drawer.getByPlaceholder('数据库或服务器域名 / IP')).toHaveCount(0);
|
||
if (['MySQL','MongoDB'].includes(type)) await expect(drawer.getByRole('switch', { name: '只读模式' })).not.toBeChecked();
|
||
await drawer.getByRole('tab', { name: '账号', exact: true }).click();
|
||
const accountEditor = drawer.locator('.gateway-accounts');
|
||
await accountEditor.getByRole('textbox', { name: '用户名', exact: true }).fill('gateway');
|
||
await accountEditor.getByRole('textbox', { name: '密码', exact: true }).fill('gateway-test-password');
|
||
await accountEditor.getByRole('tab', { name: '目标', exact: true }).click();
|
||
await accountEditor.getByRole('combobox', { name: '目标服务', exact: true }).click();
|
||
await page.getByRole('option', { name: targetName, exact: true }).click();
|
||
await accountEditor.getByRole('combobox', { name: '目标账号', exact: true }).click();
|
||
await page.getByRole('option', { name: /^普通账号 · / }).click();
|
||
await accountEditor.getByRole('tab', { name: '权限', exact: true }).click();
|
||
await expect(drawer.getByText(/仅限制当前账号,需同时满足网关白名单/)).toBeVisible();
|
||
await drawer.getByRole('button', { name: '保存配置' }).click();
|
||
await expect(drawer).not.toBeVisible();
|
||
}
|
||
|
||
test('默认免登录,目标复用与网关独立只读策略', async ({ page }) => {
|
||
const errors: string[] = [];
|
||
page.on('pageerror', error => errors.push(error.message));
|
||
await page.goto('/');
|
||
await expect(page.getByRole('heading', { name: '网关服务', exact: true })).toBeVisible();
|
||
await expect(page.getByRole('heading', { name: '登录管理控制台' })).toHaveCount(0);
|
||
await target(page, 'MySQL', '共享数据库');
|
||
await gateway(page, 'MySQL', '共享数据库', '分析入口');
|
||
await gateway(page, 'MySQL', '共享数据库', '业务入口');
|
||
const row = page.getByRole('row').filter({ hasText: '分析入口' });
|
||
await row.getByRole('button', { name: '配置', exact: true }).click();
|
||
await page.getByRole('dialog').locator('.setting-row').filter({ hasText: '只读模式' }).locator('.el-switch__core').click();
|
||
await page.getByRole('dialog').getByRole('button', { name: '保存配置' }).click();
|
||
await expect(row).toContainText('只读');
|
||
await expect(page.getByRole('row').filter({ hasText: '业务入口' })).toContainText('读写');
|
||
await page.getByRole('button', { name: /目标服务/ }).first().click();
|
||
const card = page.locator('.target-card').filter({ hasText: '共享数据库' });
|
||
await expect(card).toContainText('被 2 个网关引用');
|
||
await expect(card.getByRole('button', { name: '删除目标' })).toBeDisabled();
|
||
await card.getByRole('button', { name: '配置目标' }).click();
|
||
await page.getByRole('dialog').getByPlaceholder('数据库或服务器域名 / IP').fill('new.internal');
|
||
await page.getByRole('dialog').getByRole('button', { name: '保存配置' }).click();
|
||
await expect(card).toContainText('new.internal');
|
||
const views = await (await page.request.get('/api/v1/proxies')).json();
|
||
const shared = views.filter((v: any) => ['分析入口', '业务入口'].includes(v.config.name));
|
||
expect(shared).toHaveLength(2);
|
||
for (const view of shared) expect(view.config.allowCIDRs || []).toEqual([]);
|
||
expect(shared[0].config.gatewayAccounts[0].targetId).toBe(shared[1].config.gatewayAccounts[0].targetId);
|
||
for (const view of shared) { expect(view.config.targetHost).toBe('new.internal'); expect(view.secretStatus.targetPassword).toBe(true); }
|
||
await card.getByRole('button', { name: '查看密码' }).click();
|
||
const secret = page.getByRole('dialog');
|
||
await expect(secret.getByText('登录密码')).toHaveCount(0);
|
||
await expect(secret.getByRole('button', { name: '验证并查看' })).toHaveCount(0);
|
||
await expect(secret.locator('.secret-result').first()).toContainText('upstream');
|
||
await expect(secret.locator('.secret-result').last()).toContainText('target-test-password');
|
||
await secret.getByRole('button', { name: '关闭' }).click();
|
||
await page.route('**/api/v1/targets/*/test?*', route => route.fulfill({ json: { version: '5.7.44', tls: true, compression: 'zlib', fingerprint: 'SHA256:test-fingerprint' } }));
|
||
await card.getByRole('button', { name: '测试连接' }).click();
|
||
const connection = page.getByRole('dialog', { name: '连接测试', exact: true });
|
||
for (const value of ['5.7.44', '已启用并验证证书', 'zlib', 'SHA256:test-fingerprint', 'new.internal:3306', '测试总耗时']) await expect(connection).toContainText(value);
|
||
await connection.getByRole('button', { name: '关闭', exact: true }).click();
|
||
await page.route('**/api/v1/targets/*/test?*', route => route.fulfill({ status: 422, json: { error: 'SOCKS5 authentication failed', stage: 'socks5_authentication' } }));
|
||
await card.getByRole('button', { name: '测试连接' }).click();
|
||
await expect(connection).toContainText('连接失败');
|
||
await expect(connection).toContainText('socks5_authentication');
|
||
await expect(connection).toContainText('SOCKS5 代理认证');
|
||
await expect(connection).not.toContainText('SOCKS5 authentication failed');
|
||
await connection.getByRole('button', { name: '关闭', exact: true }).click();
|
||
expect(errors).toEqual([]);
|
||
});
|
||
|
||
test('目标支持 SSH 转发出口并保留凭据', async ({ page }) => {
|
||
await page.goto('/');
|
||
await createOutbound(page, 'SSH 跳板代理', 'ssh');
|
||
await target(page, 'MySQL', 'SSH 跳板目标', 'SSH 跳板代理');
|
||
const card = page.locator('.target-card').filter({ hasText: 'SSH 跳板目标' });
|
||
await expect(card).toContainText('SSH 转发');
|
||
const views = await (await page.request.get('/api/v1/targets')).json();
|
||
const view = views.find((v: any) => v.config.name === 'SSH 跳板目标');
|
||
expect(view.config.outboundId).toBeTruthy();
|
||
expect(view.config.outbound.port).toBe(22);
|
||
expect(view.config.outbound.skipHostKeyVerify).toBe(true);
|
||
expect(view.config.outbound.password).toBeUndefined();
|
||
await card.getByRole('button', { name: '查看密码' }).click();
|
||
const dialog = page.getByRole('dialog');
|
||
await dialog.locator('.el-select__wrapper').click();
|
||
await page.getByRole('option', { name: 'SSH 转发密码' }).click();
|
||
await expect(dialog.locator('.secret-result').first()).toContainText('jump');
|
||
await expect(dialog.locator('.secret-result').last()).toContainText('jump-test-password');
|
||
});
|
||
|
||
test('MongoDB、SSH 目标独立保存 SOCKS5 凭据', async ({ page }) => {
|
||
await page.goto('/');
|
||
for (const [type, name] of [['MongoDB', '文档目标'], ['SSH', '运维目标']]) {
|
||
await target(page, type, name, true);
|
||
await gateway(page, type, name, name + '入口');
|
||
}
|
||
const targets = await (await page.request.get('/api/v1/targets')).json();
|
||
for (const name of ['文档目标', '运维目标']) {
|
||
const view = targets.find((t: any) => t.config.name === name);
|
||
expect(view.secretStatus[`account:${view.config.accounts[0].id}:password`]).toBe(true);
|
||
expect(view.secretStatus.socksPassword).toBe(true);
|
||
expect(JSON.stringify(view)).not.toContain('socks-test-password');
|
||
expect(view.gatewayIds).toHaveLength(1);
|
||
expect(view.config.targetCompression).toBe(name === '文档目标' ? 'zlib' : 'none');
|
||
}
|
||
});
|
||
|
||
test('共享代理菜单维护、多目标引用与删除保护', async ({ page }) => {
|
||
await page.goto('/');
|
||
await createOutbound(page, '共享出口管理测试', 'socks5');
|
||
await target(page, 'MySQL', '共享出口 MySQL', '共享出口管理测试');
|
||
await target(page, 'MongoDB', '共享出口 MongoDB', '共享出口管理测试');
|
||
await page.getByRole('navigation').getByRole('button', { name: /代理管理/ }).click();
|
||
const row = page.getByRole('row').filter({ hasText: '共享出口管理测试' });
|
||
await expect(row).toContainText('共享出口 MySQL');
|
||
await expect(row).toContainText('共享出口 MongoDB');
|
||
await expect(row.getByRole('button', { name: '删除代理' })).toBeDisabled();
|
||
const profiles = await (await page.request.get('/api/v1/outbounds')).json();
|
||
const profile = profiles.find((v: any) => v.config.name === '共享出口管理测试');
|
||
expect(profile.targetIds).toHaveLength(2);
|
||
expect(profile.secretStatus.password).toBe(true);
|
||
expect(JSON.stringify(profile)).not.toContain('socks-test-password');
|
||
const session = await (await page.request.get('/api/v1/session')).json();
|
||
const deletion = await page.request.delete(`/api/v1/outbounds/${profile.config.id}`, { headers: { 'X-CSRF-Token': session.csrfToken } });
|
||
expect(deletion.status()).toBe(409);
|
||
await row.getByRole('button', { name: '编辑代理' }).click();
|
||
const drawer = page.getByRole('dialog');
|
||
await drawer.locator('.el-form-item').filter({ hasText: /^SOCKS5 主机/ }).getByRole('textbox').fill('updated.proxy.internal');
|
||
await drawer.getByRole('button', { name: '保存代理' }).click();
|
||
await expect(drawer).not.toBeVisible();
|
||
await page.reload();
|
||
const targets = await (await page.request.get('/api/v1/targets')).json();
|
||
const linked = targets.filter((v: any) => v.config.outboundId === profile.config.id);
|
||
expect(linked).toHaveLength(2);
|
||
for (const view of linked) {
|
||
expect(view.config.outbound.host).toBe('updated.proxy.internal');
|
||
expect(view.secretStatus.socksPassword).toBe(true);
|
||
}
|
||
for (const name of ['共享出口 MySQL', '共享出口 MongoDB']) {
|
||
await page.getByRole('button', { name: /目标服务/ }).first().click();
|
||
await page.locator('.target-card').filter({ hasText: name }).getByRole('button', { name: '配置目标' }).click();
|
||
await drawer.getByRole('tab', { name: '连接方式', exact: true }).click();
|
||
await drawer.getByRole('radiogroup', { name: '连接方式', exact: true }).getByRole('radio', { name: '直接连接', exact: true }).locator('..').click();
|
||
await drawer.getByRole('button', { name: '保存配置' }).click();
|
||
await expect(drawer).not.toBeVisible();
|
||
}
|
||
await page.getByRole('navigation').getByRole('button', { name: /代理管理/ }).click();
|
||
await expect(row.getByRole('button', { name: '删除代理' })).toBeEnabled();
|
||
await row.getByRole('button', { name: '删除代理' }).click();
|
||
const impact = page.getByRole('dialog', { name: '代理影响范围', exact: true });
|
||
await expect(impact).toContainText('没有配置引用,可以编辑或删除。');
|
||
await expect(impact.getByRole('button', { name: '继续删除', exact: true })).toBeEnabled();
|
||
await impact.getByRole('button', { name: '继续删除', exact: true }).click();
|
||
await page.getByRole('dialog', { name: '删除代理', exact: true }).getByRole('button', { name: '删除', exact: true }).click();
|
||
await expect(row).toHaveCount(0);
|
||
});
|
||
|
||
test('Redis、RabbitMQ、PostgreSQL 配置与事件导出',async({page})=>{
|
||
await page.goto('/');
|
||
for(const type of ['Redis','RabbitMQ','PostgreSQL']){await target(page,type,type+'目标',true);await gateway(page,type,type+'目标',type+'网关')}
|
||
const targets=await(await page.request.get('/api/v1/targets')).json();
|
||
for(const [type,port,name] of [['redis',6379,'Redis目标'],['rabbitmq',5672,'RabbitMQ目标'],['pgsql',5432,'PostgreSQL目标']]){const row=targets.find((t:any)=>t.config.type===type&&t.config.name===name);expect(row).toBeTruthy();expect(row.config.targetPort).toBe(port);expect(row.config.outbound.mode).toBe('socks5')}
|
||
await page.getByRole('button',{name:/连接与审计/}).click();
|
||
await page.getByPlaceholder('事件详情关键字').fill('redis');
|
||
await page.getByRole('button',{name:'查询',exact:true}).click();
|
||
const download=page.waitForEvent('download');await page.getByRole('button',{name:'导出 CSV'}).click();expect((await download).suggestedFilename()).toBe('linkport-events.csv');
|
||
});
|
||
|
||
test('主题切换、持久化、跟随系统及移动布局', async ({ page }) => {
|
||
await page.goto('/');
|
||
for (const value of ['light', 'ocean', 'dark']) {
|
||
await page.getByLabel('主题', { exact: true }).selectOption(value);
|
||
await expect(page.locator('html')).toHaveAttribute('data-theme', value);
|
||
await page.screenshot({ path: `test-results/theme-${value}.png`, fullPage: true });
|
||
}
|
||
await page.reload();
|
||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||
await page.getByRole('button', { name: '+ 新建网关' }).click();
|
||
await expect(page.getByRole('dialog')).toBeVisible();
|
||
await page.screenshot({ path: 'test-results/theme-dark-drawer.png', fullPage: true });
|
||
await page.getByRole('dialog').getByRole('button', { name: '取消', exact: true }).click();
|
||
await page.getByLabel('主题', { exact: true }).selectOption('system');
|
||
await page.emulateMedia({ colorScheme: 'light' });
|
||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
||
await page.emulateMedia({ colorScheme: 'dark' });
|
||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
||
await page.screenshot({ path: 'test-results/theme-mobile.png', fullPage: true });
|
||
await page.getByRole('button', { name: /命令审计/ }).click();
|
||
await expect(page.getByRole('heading', { name: '命令审计', exact: true })).toBeVisible();
|
||
await expect(page.locator('.command-mobile').getByText('暂无命令记录', { exact: true })).toBeVisible();
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
||
});
|