Files
LinkPort/web/tests/workspace.spec.ts

282 lines
19 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, 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);
});