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

234 lines
16 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';
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(() => {});
}
});