feat(pages): 支持 Remote 部署源同步

新增部署源配置与运行态模型、安全下载、租约续期、原子激活和失败补偿。

接入内部任务与脱敏前端交互,并阻止数据库 Trace 和日志展开敏感查询参数。
This commit is contained in:
deqiying
2026-07-19 17:36:51 +08:00
parent 4e8ec23264
commit 38b0516937
58 changed files with 9858 additions and 1075 deletions
+98 -7
View File
@@ -2,27 +2,34 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AxiosResponse } from 'axios';
import apiClient from '@/lib/services/core/api-client';
import { apiConfig } from '@/lib/services/core/config';
import { PagesService } from '@/lib/services/openflare/pages.service';
vi.mock('@/lib/services/core/api-client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}));
function response<T>(data: T) {
return {
data: { error_msg: '', data },
status: 200,
statusText: 'OK',
headers: {},
config: { headers: {} },
} as AxiosResponse;
}
describe('PagesService', () => {
beforeEach(() => {
vi.mocked(apiClient.get).mockReset();
vi.mocked(apiClient.post).mockReset();
});
it('requests deployment files using the backend deployment route', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: { error_msg: '', data: [] },
status: 200,
statusText: 'OK',
headers: {},
config: { headers: {} },
} as AxiosResponse);
vi.mocked(apiClient.get).mockResolvedValue(response([]));
await PagesService.listDeploymentFiles(7);
@@ -31,4 +38,88 @@ describe('PagesService', () => {
expect.objectContaining({ params: undefined }),
);
});
it('connects all source endpoints with explicit action payloads', async () => {
vi.mocked(apiClient.get).mockResolvedValue(
response({ source_type: 'manual' }),
);
vi.mocked(apiClient.post).mockResolvedValue(
response({ source_type: 'manual' }),
);
await PagesService.getSource(12);
await PagesService.updateSource(12, {
source_type: 'remote_url',
remote_url_set: true,
remote_url: 'https://example.com/site.zip?token=secret',
remote_network_policy: 'public',
});
await PagesService.deleteSource(12);
await PagesService.checkSource(12);
await PagesService.syncSource(12);
expect(apiClient.get).toHaveBeenCalledWith(
'/api/v1/d/pages/12/source',
expect.objectContaining({ params: undefined }),
);
expect(apiClient.post).toHaveBeenNthCalledWith(
1,
'/api/v1/d/pages/12/source/update',
expect.objectContaining({
source_type: 'remote_url',
remote_url_set: true,
}),
undefined,
);
expect(apiClient.post).toHaveBeenNthCalledWith(
2,
'/api/v1/d/pages/12/source/delete',
undefined,
undefined,
);
expect(apiClient.post).toHaveBeenNthCalledWith(
3,
'/api/v1/d/pages/12/source/check',
{},
undefined,
);
expect(apiClient.post).toHaveBeenNthCalledWith(
4,
'/api/v1/d/pages/12/source/sync',
{},
undefined,
);
});
it('uploads only the package multipart field', async () => {
vi.mocked(apiClient.post).mockResolvedValue(response({}));
const file = new File(['site'], 'site.zip', {
type: 'application/zip',
});
await PagesService.uploadDeployment(8, { file });
const formData = vi.mocked(apiClient.post).mock.calls[0]?.[1];
expect(formData).toBeInstanceOf(FormData);
expect(Array.from((formData as FormData).keys())).toEqual(['package']);
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/d/pages/8/deployments/upload',
formData,
expect.objectContaining({ timeout: apiConfig.uploadTimeout }),
);
});
it('keeps the compatibility URL import on the long upload timeout', async () => {
vi.mocked(apiClient.post).mockResolvedValue(response({}));
await PagesService.uploadDeploymentFromURL(8, {
url: 'https://example.com/site.zip',
});
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/d/pages/8/deployments/upload-from-url',
{ url: 'https://example.com/site.zip' },
expect.objectContaining({ timeout: apiConfig.uploadTimeout }),
);
});
});
@@ -0,0 +1,227 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DeploymentUploadDialog } from '@/app/(main)/pages/components/deployment-upload-dialog';
import { DeploymentHistory } from '@/app/(main)/pages/detail/components/deployment-history';
import { PagesSourceCard } from '@/app/(main)/pages/detail/components/pages-source-card';
import { AdminTaskService } from '@/lib/services/admin';
import {
type PagesRemoteURLSource,
PagesService,
} from '@/lib/services/openflare';
vi.mock('@/lib/services/openflare', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@/lib/services/openflare')>();
return {
...actual,
PagesService: {
getSource: vi.fn(),
updateSource: vi.fn(),
deleteSource: vi.fn(),
syncSource: vi.fn(),
listDeployments: vi.fn(),
listDeploymentFiles: vi.fn(),
activateDeployment: vi.fn(),
deleteDeployment: vi.fn(),
uploadDeployment: vi.fn(),
},
};
});
vi.mock('@/lib/services/admin', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/services/admin')>();
return {
...actual,
AdminTaskService: {
getTaskExecution: vi.fn(),
},
};
});
function renderWithQuery(ui: React.ReactNode) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
);
}
const remoteSource: PagesRemoteURLSource = {
source_type: 'remote_url',
has_remote_url: true,
display_url: 'https://artifacts.example.com/site.zip?***',
remote_network_policy: 'public',
sync_status: 'idle',
last_applied: {
revision: 'a'.repeat(64),
label: 'site.zip',
},
last_synced_at: '2026-07-19T10:00:00Z',
last_error: '',
};
describe('Pages source UI', () => {
beforeEach(() => {
vi.mocked(PagesService.getSource).mockReset();
vi.mocked(PagesService.updateSource).mockReset();
vi.mocked(PagesService.deleteSource).mockReset();
vi.mocked(PagesService.syncSource).mockReset();
vi.mocked(PagesService.listDeployments).mockReset();
vi.mocked(PagesService.listDeploymentFiles).mockReset();
vi.mocked(PagesService.activateDeployment).mockReset();
vi.mocked(PagesService.deleteDeployment).mockReset();
vi.mocked(PagesService.uploadDeployment).mockReset();
vi.mocked(AdminTaskService.getTaskExecution).mockReset();
});
it('keeps Phase 1 manual source focused on upload and Remote URL', async () => {
vi.mocked(PagesService.getSource).mockResolvedValue({
source_type: 'manual',
});
renderWithQuery(<PagesSourceCard projectId={9} />);
expect(await screen.findByText('本地部署包')).toBeVisible();
expect(
screen.getByRole('button', { name: '配置 Remote URL' }),
).toBeVisible();
expect(screen.queryByText('检查更新')).not.toBeInTheDocument();
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
});
it('never reuses the masked URL as an editable value', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(remoteSource);
vi.mocked(PagesService.updateSource).mockResolvedValue({
source: remoteSource,
check_task: null,
warning: '',
});
renderWithQuery(<PagesSourceCard projectId={9} />);
expect(
await screen.findByText('https://artifacts.example.com/site.zip?***'),
).toBeVisible();
expect(screen.queryByText(/token=secret/)).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '编辑来源' }));
await user.click(screen.getByRole('button', { name: '更换地址' }));
const input = screen.getByPlaceholderText(
'https://artifacts.example.com/site.zip?token=...',
);
expect(input).toHaveValue('');
await user.type(input, 'https://new.example.com/site.zip?token=new');
await user.click(screen.getByRole('button', { name: '保存 Remote 来源' }));
await waitFor(() => {
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
source_type: 'remote_url',
remote_url_set: true,
remote_url: 'https://new.example.com/site.zip?token=new',
remote_network_policy: 'public',
});
});
});
it('requires a second confirmation for trusted internal networking', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(remoteSource);
vi.mocked(PagesService.updateSource).mockResolvedValue({
source: { ...remoteSource, remote_network_policy: 'trusted_internal' },
check_task: null,
warning: '',
});
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '编辑来源' }));
await user.click(screen.getByRole('radio', { name: '受信内网模式' }));
await user.click(screen.getByRole('button', { name: '保存 Remote 来源' }));
expect(await screen.findByText('启用受信内网模式')).toBeVisible();
expect(PagesService.updateSource).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
source_type: 'remote_url',
remote_url_set: false,
remote_url: '',
remote_network_policy: 'trusted_internal',
});
});
});
it('polls the existing task execution detail after sync dispatch', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(remoteSource);
vi.mocked(PagesService.syncSource).mockResolvedValue({
task_id: 'manual_of_pages_source_action_1',
execution_id: '42',
action: 'sync',
});
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue({
id: '42',
task_id: 'manual_of_pages_source_action_1',
task_type: 'of_pages_source_action',
task_name: 'Pages 来源动作',
status: 'succeeded',
retryable: false,
max_retry: 0,
retry_count: 0,
log: '',
error_message: '',
result: '',
duration: 1,
payload: '',
triggered_by: 'admin:1',
created_at: '2026-07-19T10:00:00Z',
updated_at: '2026-07-19T10:00:01Z',
});
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '同步并发布' }));
await waitFor(() => {
expect(PagesService.syncSource).toHaveBeenCalledWith(9, {});
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
});
});
it('shows the actual project entry and no one-off URL upload tab', () => {
renderWithQuery(
<DeploymentUploadDialog
open
onOpenChange={vi.fn()}
projectId={9}
rootDir='dist/site'
entryFile='home.html'
/>,
);
expect(screen.getByText('dist/site/home.html')).toBeVisible();
expect(screen.queryByText('从 URL 下载')).not.toBeInTheDocument();
expect(screen.queryByText('部署包下载链接')).not.toBeInTheDocument();
});
it('renders a deployment query failure instead of an empty history', async () => {
vi.mocked(PagesService.listDeployments).mockRejectedValue(
new Error('部署历史暂时不可用'),
);
renderWithQuery(<DeploymentHistory projectId={9} />);
expect(await screen.findByText('部署历史暂时不可用')).toBeVisible();
expect(screen.queryByText('暂无部署')).not.toBeInTheDocument();
});
});