234 lines
16 KiB
TypeScript
234 lines
16 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
||
|
||
const initialToken = 'linkport-e2e-initial-kube-token';
|
||
const replacementToken = 'linkport-e2e-replacement-kube-token';
|
||
const kubeconfig = (token: string) => `apiVersion: v1
|
||
kind: Config
|
||
clusters:
|
||
- name: isolated-cluster
|
||
cluster:
|
||
server: https://127.0.0.1:1
|
||
insecure-skip-tls-verify: true
|
||
contexts:
|
||
- name: test-cluster
|
||
context:
|
||
cluster: isolated-cluster
|
||
user: isolated-user
|
||
namespace: demo
|
||
current-context: test-cluster
|
||
users:
|
||
- name: isolated-user
|
||
user:
|
||
token: ${token}
|
||
`;
|
||
|
||
test('新建 Kubeconfig 代理时上传配置、共享引用与版本信息', async ({ page }) => {
|
||
await page.goto('/');
|
||
const build = await (await page.request.get('/api/v1/version')).json();
|
||
await page.getByRole('button', { name: '版本信息', exact: true }).click();
|
||
await expect(page.getByRole('dialog', { name: '版本信息' })).toContainText(build.version);
|
||
await expect(page.getByRole('dialog', { name: '版本信息' })).toContainText(build.platform);
|
||
await page.getByRole('dialog', { name: '版本信息' }).getByRole('button', { name: '关闭' }).click();
|
||
await page.getByRole('navigation').getByRole('button', { name: '代理管理', exact: true }).click();
|
||
await page.getByRole('button', { name: '新建代理', exact: true }).click();
|
||
const drawer = page.getByRole('dialog', { name: /^(新建代理|编辑代理|新建目标服务)$/ });
|
||
await expect(drawer.getByRole('radio', { name: 'Kubeconfig', exact: true })).toBeChecked();
|
||
await drawer.getByRole('textbox', { name: '代理名称', exact: true }).fill('K8s 测试代理');
|
||
let namespaceFailure = true;
|
||
await page.route('**/api/v1/assets/*/kubernetes-namespaces?**', route => route.fulfill(namespaceFailure
|
||
? { status: 422, json: { error: '命名空间查询失败' } }
|
||
: { json: { namespaces: ['demo', 'other'] } }));
|
||
await drawer.getByRole('button', { name: '上传 Kubeconfig', exact: true }).click();
|
||
const upload = page.getByRole('dialog', { name: '上传 Kubeconfig', exact: true });
|
||
await upload.getByRole('textbox', { name: 'Kubeconfig 名称', exact: true }).fill('K8s 测试凭据');
|
||
await upload.getByLabel('上传 Kubeconfig 文件', { exact: true }).setInputFiles({
|
||
name: 'isolated-kubeconfig.yaml', mimeType: 'application/yaml', buffer: Buffer.from(kubeconfig(initialToken)),
|
||
});
|
||
await expect(upload.getByText('已选择 isolated-kubeconfig.yaml,保存资产后生效', { exact: true })).toBeVisible();
|
||
const assetCreating = page.waitForRequest(request => request.url().endsWith('/api/v1/assets') && request.method() === 'POST');
|
||
const assetCreated = page.waitForResponse(response => response.url().endsWith('/api/v1/assets') && response.request().method() === 'POST');
|
||
await upload.getByRole('button', { name: '保存并选择 Kubeconfig', exact: true }).click();
|
||
expect((await assetCreating).postDataJSON()).toMatchObject({ name: 'K8s 测试凭据', type: 'kubeconfig', content: kubeconfig(initialToken) });
|
||
const assetResponse = await assetCreated;
|
||
expect(assetResponse.ok()).toBeTruthy();
|
||
const asset = await assetResponse.json();
|
||
expect(asset.secretStatus.content).toBe(true);
|
||
expect(asset.config.content || '').toBe('');
|
||
expect(JSON.stringify(asset)).not.toContain(initialToken);
|
||
await expect(upload).not.toBeVisible();
|
||
const session = await (await page.request.get('/api/v1/session')).json();
|
||
const headers = { 'X-CSRF-Token': session.csrfToken };
|
||
await expect(drawer.getByLabel('上传 Kubeconfig 文件', { exact: true })).toHaveCount(0);
|
||
await expect(drawer.locator('[data-field="outbound.kubeConfigAssetId"]')).toContainText('K8s 测试凭据');
|
||
await drawer.getByPlaceholder('当前 context', { exact: true }).fill('test-cluster');
|
||
const namespaceSelect = drawer.getByRole('combobox', { name: '选择命名空间', exact: true });
|
||
await expect(drawer.getByRole('alert')).toContainText('重新查询命名空间');
|
||
await expect(namespaceSelect).toBeDisabled();
|
||
namespaceFailure = false;
|
||
await drawer.getByRole('button', { name: '查询命名空间', exact: true }).click();
|
||
await expect(namespaceSelect).toBeEnabled();
|
||
await namespaceSelect.click();
|
||
await page.getByRole('option', { name: 'demo', exact: true }).click();
|
||
await page.route(`**/api/v1/assets/${asset.config.id}/kubernetes-resources?**`, route => route.fulfill({ json: {
|
||
namespace: 'demo', namespaces: ['demo'], resources: [{ kind: 'service', name: 'mysql', namespace: 'demo', ports: [3306] }], warnings: [],
|
||
} }));
|
||
await expect(drawer.getByRole('textbox', { name: 'Kubernetes 资源', exact: true })).toHaveCount(0);
|
||
await drawer.getByRole('button', { name: '查询 Kubernetes 资源', exact: true }).click();
|
||
await drawer.getByRole('combobox', { name: '选择 Kubernetes 资源', exact: true }).click();
|
||
await page.getByRole('option', { name: 'service/mysql · 端口 3306', exact: true }).click();
|
||
await expect(drawer.getByRole('radio', { name: '自动高位端口', exact: true })).toBeChecked();
|
||
await drawer.getByRole('radio', { name: '固定端口', exact: true }).locator('..').click();
|
||
await drawer.getByRole('spinbutton', { name: '固定本机端口', exact: true }).fill('55032');
|
||
await page.screenshot({ path: 'test-results/kubernetes-desktop.png' });
|
||
const creating = page.waitForRequest(request => request.url().endsWith('/api/v1/outbounds') && request.method() === 'POST');
|
||
const created = page.waitForResponse(response => response.url().endsWith('/api/v1/outbounds') && response.request().method() === 'POST');
|
||
await drawer.getByRole('button', { name: '保存代理' }).click();
|
||
const profileBody = (await creating).postDataJSON();
|
||
expect(profileBody.outbound.kubeConfigAssetId).toBe(asset.config.id);
|
||
expect(profileBody.outbound.localPort).toBe(55032);
|
||
expect(profileBody.outbound.kubeConfigContent || '').toBe('');
|
||
expect(JSON.stringify(profileBody)).not.toContain(initialToken);
|
||
const createdResponse = await created;
|
||
expect(createdResponse.ok()).toBeTruthy();
|
||
const profile = await createdResponse.json();
|
||
expect(profile.config.outbound.kubeConfigAssetId).toBe(asset.config.id);
|
||
expect(profile.config.outbound.localPort).toBe(55032);
|
||
expect(profile.config.outbound.kubeConfigContent || '').toBe('');
|
||
expect(JSON.stringify(profile)).not.toContain(initialToken);
|
||
await expect(drawer).not.toBeVisible();
|
||
const proxyRow = page.getByRole('row').filter({ hasText: 'K8s 测试代理' });
|
||
await expect(proxyRow).toContainText('Kubeconfig');
|
||
await proxyRow.getByRole('button', { name: '编辑代理', exact: true }).click();
|
||
await expect(page.getByRole('dialog', { name: '编辑代理', exact: true })).toBeVisible();
|
||
await expect(drawer.getByRole('spinbutton', { name: '固定本机端口', exact: true })).toHaveValue('55032');
|
||
await expect(drawer.locator('.el-select').filter({ has: page.getByRole('combobox', { name: 'Kubeconfig 配置', exact: true }) })).toContainText('K8s 测试凭据');
|
||
await drawer.locator('.workspace-page__footer').getByRole('button', { name: '取消', exact: true }).click();
|
||
await expect(drawer).not.toBeVisible();
|
||
const deletion = await page.request.delete(`/api/v1/assets/${asset.config.id}`, { headers });
|
||
expect(deletion.status()).toBe(409);
|
||
await page.getByRole('button', { name: /目标服务/ }).first().click();
|
||
await page.getByRole('button', { name: '+ 新建目标' }).click();
|
||
await drawer.getByPlaceholder('例如:生产数据库 · 只读分析').fill('K8s MySQL');
|
||
await drawer.getByPlaceholder('数据库或服务器域名 / IP').fill('mysql.demo.svc');
|
||
await drawer.getByRole('tab', { name: '连接方式', exact: true }).click();
|
||
await drawer.getByRole('radio', { name: 'Kubeconfig', exact: true }).locator('..').click();
|
||
await drawer.locator('.el-select').filter({ has: page.getByRole('combobox', { name: '代理', exact: true }) }).click();
|
||
await page.getByRole('option', { name: 'K8s 测试代理', exact: true }).click();
|
||
await drawer.getByRole('tab', { name: '账号', exact: true }).click();
|
||
await drawer.getByRole('textbox', { name: '账号备注', exact: true }).fill('数据库账号');
|
||
await drawer.getByRole('textbox', { name: '登录用户名', exact: true }).fill('dbuser');
|
||
await drawer.getByRole('textbox', { name: '账号密码', exact: true }).fill('test-only-password');
|
||
await drawer.getByRole('button', { name: '保存配置' }).click();
|
||
await expect(drawer).not.toBeVisible();
|
||
const card = page.locator('.target-card').filter({ hasText: 'K8s MySQL' });
|
||
await expect(card).toContainText('K8s port-forward');
|
||
await page.reload();
|
||
await page.getByRole('navigation').getByRole('button', { name: '代理管理', exact: true }).click();
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||
await page.screenshot({ path: 'test-results/outbounds-desktop.png', fullPage: true });
|
||
await page.getByRole('row').filter({ hasText: 'K8s 测试代理' }).getByRole('button', { name: '编辑代理' }).click();
|
||
await expect(drawer.getByLabel('上传 Kubeconfig 文件', { exact: true })).toHaveCount(0);
|
||
await expect(drawer.locator('.el-select').filter({ has: page.getByRole('combobox', { name: 'Kubeconfig 配置', exact: true }) })).toContainText('K8s 测试凭据');
|
||
await expect(drawer.getByPlaceholder('当前 context', { exact: true })).toHaveValue('test-cluster');
|
||
await expect(drawer.locator('.el-select').filter({ has: page.getByRole('combobox', { name: '选择 Kubernetes 资源', exact: true }) })).toContainText('service/mysql');
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await expect.poll(async () => (await drawer.boundingBox())?.x).toBeGreaterThanOrEqual(0);
|
||
await expect.poll(async () => (await drawer.boundingBox())?.width).toBeLessThanOrEqual(390);
|
||
await page.screenshot({ path: 'test-results/kubernetes-mobile.png' });
|
||
const localPortField = drawer.locator('[data-field="outbound.localPort"]');
|
||
await localPortField.scrollIntoViewIfNeeded();
|
||
await expect(drawer.getByRole('spinbutton', { name: '固定本机端口', exact: true })).toHaveValue('55032');
|
||
await page.screenshot({ path: 'test-results/kubernetes-local-port-mobile.png' });
|
||
expect(await drawer.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
|
||
await drawer.locator('.workspace-page__footer').getByRole('button', { name: '取消', exact: true }).click();
|
||
await expect(drawer).not.toBeVisible();
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||
await page.screenshot({ path: 'test-results/outbounds-mobile.png', fullPage: true });
|
||
const targets = await (await page.request.get('/api/v1/targets')).json();
|
||
const saved = targets.find((row: any) => row.config.name === 'K8s MySQL');
|
||
expect(saved.config.outboundId).toBeTruthy();
|
||
expect(saved.config.outbound).toMatchObject({ mode: 'k8s', namespace: 'demo', resource: 'service/mysql', kubeContext: 'test-cluster', kubeConfigAssetId: asset.config.id });
|
||
expect(JSON.stringify(saved)).not.toContain('test-only-password');
|
||
expect(JSON.stringify(saved)).not.toContain(initialToken);
|
||
expect(saved.config.outbound.kubeConfigContent || '').toBe('');
|
||
// Deliberately unreachable fixture endpoint; this does not validate a live Kubernetes cluster.
|
||
const result = await page.request.post(`/api/v1/targets/${saved.config.id}/test?accountId=${saved.config.accounts[0].id}`, { headers });
|
||
expect(result.ok()).toBe(false);
|
||
expect(JSON.stringify(await result.json())).toContain('k8s');
|
||
});
|
||
|
||
test('Kubeconfig 资产支持保留、重新上传和取消待保存文件', async ({ page }) => {
|
||
await page.goto('/');
|
||
const session = await (await page.request.get('/api/v1/session')).json();
|
||
const headers = { 'X-CSRF-Token': session.csrfToken };
|
||
const created = await page.request.post('/api/v1/assets', { headers, data: {
|
||
name: 'K8s 重传验证', type: 'kubeconfig', content: kubeconfig(initialToken),
|
||
} });
|
||
expect(created.ok()).toBeTruthy();
|
||
const id = (await created.json()).config.id;
|
||
try {
|
||
await page.reload();
|
||
const drawer = page.getByRole('dialog', { name: '编辑资产', exact: true });
|
||
const open = async () => {
|
||
await page.keyboard.press('Control+k');
|
||
const search = page.getByRole('dialog', { name: '搜索资源 · 当前项目与环境', exact: true });
|
||
await search.getByRole('combobox', { name: '搜索资源关键词', exact: true }).fill('K8s 重传验证');
|
||
await search.getByRole('option').filter({ hasText: 'K8s 重传验证' }).click();
|
||
await expect(drawer.getByText('已保存 Kubeconfig,可重新上传替换', { exact: true })).toBeVisible();
|
||
};
|
||
const upload = async (name: string, token: string) => {
|
||
await drawer.getByLabel('上传 Kubeconfig 文件', { exact: true }).setInputFiles({
|
||
name, mimeType: 'application/yaml', buffer: Buffer.from(kubeconfig(token)),
|
||
});
|
||
await expect(drawer.getByText(`已选择 ${name},保存资产后生效`, { exact: true })).toBeVisible();
|
||
};
|
||
const save = async (content?: string) => {
|
||
const saving = page.waitForRequest(request => request.url().endsWith(`/api/v1/assets/${id}`) && request.method() === 'PUT');
|
||
const saved = page.waitForResponse(response => response.url().endsWith(`/api/v1/assets/${id}`) && response.request().method() === 'PUT');
|
||
await drawer.getByRole('button', { name: '保存资产' }).click();
|
||
const body = (await saving).postDataJSON();
|
||
if (content === undefined) expect(body).not.toHaveProperty('content');
|
||
else expect(body.content).toBe(content);
|
||
const response = await saved;
|
||
expect(response.ok()).toBeTruthy();
|
||
const view = await response.json();
|
||
expect(view.secretStatus.content).toBe(true);
|
||
expect(view.config.content || '').toBe('');
|
||
expect(JSON.stringify(view)).not.toContain(initialToken);
|
||
expect(JSON.stringify(view)).not.toContain(replacementToken);
|
||
await expect(drawer).not.toBeVisible();
|
||
};
|
||
await open();
|
||
await save(); // Unchanged edits must not erase the stored secret.
|
||
await open();
|
||
await upload('renewed-kubeconfig.yaml', replacementToken);
|
||
await save(kubeconfig(replacementToken));
|
||
await open();
|
||
await upload('cancelled-kubeconfig.yaml', initialToken);
|
||
await drawer.getByRole('button', { name: '取消本次上传', exact: true }).click();
|
||
await expect(drawer.getByText('已选择 cancelled-kubeconfig.yaml,保存资产后生效', { exact: true })).toHaveCount(0);
|
||
await expect(drawer.getByText('已保存 Kubeconfig,可重新上传替换', { exact: true })).toBeVisible();
|
||
await save();
|
||
await open();
|
||
await drawer.getByLabel('上传 Kubeconfig 文件', { exact: true }).setInputFiles({ name: 'empty.yaml', mimeType: 'application/yaml', buffer: Buffer.alloc(0) });
|
||
await expect(page.getByText('Kubeconfig 文件不能为空', { exact: true })).toBeVisible();
|
||
await expect(drawer.getByText('已选择 empty.yaml,保存资产后生效', { exact: true })).toHaveCount(0);
|
||
await save();
|
||
await open();
|
||
await upload('discarded-kubeconfig.yaml', initialToken);
|
||
await drawer.locator('.workspace-page__footer').getByRole('button', { name: '取消', exact: true }).click();
|
||
await page.getByRole('button', { name: '放弃更改', exact: true }).click();
|
||
await expect(drawer).not.toBeVisible();
|
||
await open();
|
||
await expect(drawer.getByText('已选择 discarded-kubeconfig.yaml,保存资产后生效', { exact: true })).toHaveCount(0);
|
||
await save();
|
||
const assets = await (await page.request.get('/api/v1/assets')).json();
|
||
const asset = assets.find((row: any) => row.config.id === id);
|
||
expect(asset.secretStatus.content).toBe(true);
|
||
expect(JSON.stringify(assets)).not.toContain(initialToken);
|
||
expect(JSON.stringify(assets)).not.toContain(replacementToken);
|
||
} finally {
|
||
await page.request.delete(`/api/v1/assets/${id}`, { headers }).catch(() => {});
|
||
}
|
||
});
|