@@ -101,38 +205,77 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
) : null}
-
- {(wafQuery.data?.rule_groups ?? []).map((group) => (
-
+ ) : null}
{(wafQuery.data?.rule_groups ?? []).length === 0 ? (
diff --git a/frontend/app/(main)/waf/components/helpers.ts b/frontend/app/(main)/waf/components/helpers.ts
index a59a1859..26d4ea34 100644
--- a/frontend/app/(main)/waf/components/helpers.ts
+++ b/frontend/app/(main)/waf/components/helpers.ts
@@ -1,115 +1,12 @@
import type {
- ProxyRoutePoWConfig,
WAFIPGroup,
WAFIPGroupPayload,
- WAFRuleGroup,
- WAFRuleGroupPayload,
} from '@/lib/services/openflare';
-export type WAFTab = 'basic' | 'lists' | 'pow' | 'block';
-export type RuleListType = 'whitelist' | 'blacklist';
-export type RuleDimension = 'ip' | 'ip_group' | 'country';
-export type ListFieldKey =
- | 'ip_whitelist'
- | 'ip_blacklist'
- | 'ip_whitelist_group_ids'
- | 'ip_blacklist_group_ids'
- | 'country_whitelist'
- | 'country_blacklist';
-
-export type CountryOption = {
- code: string;
- zhName: string;
- label: string;
- searchText: string;
-};
-
-export type RuleModalState = {
- open: boolean;
- listType: RuleListType;
- dimension: RuleDimension;
- ipValue: string;
- ipGroupIDs: number[];
- countryValues: string[];
-};
-
-export type RuleListRenderable = Pick<
- WAFRuleGroupPayload,
- | 'ip_whitelist'
- | 'ip_blacklist'
- | 'ip_whitelist_group_ids'
- | 'ip_blacklist_group_ids'
- | 'country_whitelist'
- | 'country_blacklist'
- | 'region_whitelist'
- | 'region_blacklist'
->;
-
-export const defaultPowConfig: ProxyRoutePoWConfig = {
- difficulty: 4,
- algorithm: 'fast',
- session_ttl: 600,
- challenge_ttl: 300,
- whitelist: {
- ips: [],
- ip_cidrs: [],
- paths: [],
- path_regexes: [],
- user_agents: [],
- },
- blacklist: {
- ips: [],
- ip_cidrs: [],
- paths: [],
- path_regexes: [],
- user_agents: [],
- },
-};
-
-export const emptyRuleGroupDraft: WAFRuleGroupPayload = {
- name: '',
- enabled: true,
- block_status_code: 418,
- block_response_body: '',
- ip_whitelist: [],
- ip_blacklist: [],
- ip_whitelist_group_ids: [],
- ip_blacklist_group_ids: [],
- country_whitelist: [],
- country_blacklist: [],
- region_whitelist: [],
- region_blacklist: [],
- pow_enabled: false,
- pow_config: defaultPowConfig,
-};
-
-export const defaultRuleModalState: RuleModalState = {
- open: false,
- listType: 'blacklist',
- dimension: 'ip',
- ipValue: '',
- ipGroupIDs: [],
- countryValues: [],
-};
-
-export const wafTabItems: Array<{ id: WAFTab; label: string }> = [
- { id: 'basic', label: '基本信息' },
- { id: 'lists', label: '黑白名单' },
- { id: 'pow', label: 'PoW' },
- { id: 'block', label: '拦截返回' },
-];
-
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '操作失败';
}
-export function textToList(text: string) {
- return text
- .split(/[\n,,\s]+/)
- .map((item) => item.trim())
- .filter(Boolean);
-}
-
export function listToText(items: string[] | undefined) {
return (items ?? []).join('\n');
}
@@ -121,130 +18,6 @@ export function parseTextareaList(text: string) {
.filter(Boolean);
}
-export function normalizeItems(items: string[]) {
- return Array.from(
- new Set(items.map((item) => item.trim()).filter(Boolean)),
- ).sort((left, right) => left.localeCompare(right));
-}
-
-export function buildRuleGroupDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload {
- if (!group) {
- return { ...emptyRuleGroupDraft };
- }
- return {
- name: group.name,
- enabled: group.enabled,
- block_status_code: group.block_status_code || 418,
- block_response_body: group.block_response_body ?? '',
- ip_whitelist: group.ip_whitelist ?? [],
- ip_blacklist: group.ip_blacklist ?? [],
- ip_whitelist_group_ids: group.ip_whitelist_group_ids ?? [],
- ip_blacklist_group_ids: group.ip_blacklist_group_ids ?? [],
- country_whitelist: group.country_whitelist ?? [],
- country_blacklist: group.country_blacklist ?? [],
- region_whitelist: group.region_whitelist ?? [],
- region_blacklist: group.region_blacklist ?? [],
- pow_enabled: group.pow_enabled ?? false,
- pow_config: group.pow_config ?? defaultPowConfig,
- };
-}
-
-export function countRuleEntries(group: RuleListRenderable) {
- return (
- group.ip_whitelist.length +
- group.ip_blacklist.length +
- group.ip_whitelist_group_ids.length +
- group.ip_blacklist_group_ids.length +
- group.country_whitelist.length +
- group.country_blacklist.length +
- group.region_whitelist.length +
- group.region_blacklist.length
- );
-}
-
-export function buildCountryOptions() {
- const zhDisplayNames = new Intl.DisplayNames(['zh-CN'], { type: 'region' });
- const enDisplayNames = new Intl.DisplayNames(['en'], { type: 'region' });
- const options: CountryOption[] = [];
-
- for (let first = 65; first <= 90; first += 1) {
- for (let second = 65; second <= 90; second += 1) {
- const code = String.fromCharCode(first, second);
- const zhName = zhDisplayNames.of(code);
- const enName = enDisplayNames.of(code);
-
- if (
- !zhName ||
- zhName === code ||
- /未知/.test(zhName) ||
- !enName ||
- enName === code ||
- /Unknown/.test(enName)
- ) {
- continue;
- }
-
- options.push({
- code,
- zhName,
- label: `${code} ${zhName}`,
- searchText: `${code} ${zhName} ${enName}`.toLowerCase(),
- });
- }
- }
-
- return options.sort((left, right) => left.code.localeCompare(right.code));
-}
-
-export function getListFieldKey(
- listType: RuleListType,
- dimension: RuleDimension,
-): ListFieldKey {
- if (dimension === 'ip') {
- return listType === 'whitelist' ? 'ip_whitelist' : 'ip_blacklist';
- }
- if (dimension === 'ip_group') {
- return listType === 'whitelist'
- ? 'ip_whitelist_group_ids'
- : 'ip_blacklist_group_ids';
- }
- return listType === 'whitelist' ? 'country_whitelist' : 'country_blacklist';
-}
-
-export function updateDraftList(
- draft: WAFRuleGroupPayload,
- key: ListFieldKey,
- updater: (items: string[]) => string[],
-) {
- switch (key) {
- case 'ip_whitelist':
- return { ...draft, ip_whitelist: updater(draft.ip_whitelist) };
- case 'ip_blacklist':
- return { ...draft, ip_blacklist: updater(draft.ip_blacklist) };
- case 'ip_whitelist_group_ids':
- return {
- ...draft,
- ip_whitelist_group_ids: updater(
- draft.ip_whitelist_group_ids.map(String),
- ).map(Number),
- };
- case 'ip_blacklist_group_ids':
- return {
- ...draft,
- ip_blacklist_group_ids: updater(
- draft.ip_blacklist_group_ids.map(String),
- ).map(Number),
- };
- case 'country_whitelist':
- return { ...draft, country_whitelist: updater(draft.country_whitelist) };
- case 'country_blacklist':
- return { ...draft, country_blacklist: updater(draft.country_blacklist) };
- }
-}
-
-export function formatCountryItem(code: string, labelMap: Map
) {
- return labelMap.get(code) ?? code;
-}
export function parseAutomaticConfig(text: string): Record {
const parsed = JSON.parse(text || '{}') as unknown;
diff --git a/frontend/app/(main)/waf/components/pow-config-panel.tsx b/frontend/app/(main)/waf/components/pow-config-panel.tsx
deleted file mode 100644
index 7a46ea17..00000000
--- a/frontend/app/(main)/waf/components/pow-config-panel.tsx
+++ /dev/null
@@ -1,244 +0,0 @@
-'use client';
-
-import {useEffect, useState} from 'react';
-
-import {Input} from '@/components/ui/input';
-import {Label} from '@/components/ui/label';
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select';
-import {Switch} from '@/components/ui/switch';
-import {Textarea} from '@/components/ui/textarea';
-import type {ProxyRoutePoWConfig} from '@/lib/services/openflare';
-
-import {listToText, parseTextareaList} from './helpers';
-
-interface PowConfigPanelProps {
- enabled: boolean;
- config: ProxyRoutePoWConfig;
- onChange: (enabled: boolean, config: ProxyRoutePoWConfig) => void;
-}
-
-type PowListDraft = {
- whitelist: Record;
- blacklist: Record;
-};
-
-function buildDraft(config: ProxyRoutePoWConfig): PowListDraft {
- return {
- whitelist: {
- ips: listToText(config.whitelist?.ips),
- ip_cidrs: listToText(config.whitelist?.ip_cidrs),
- paths: listToText(config.whitelist?.paths),
- path_regexes: listToText(config.whitelist?.path_regexes),
- user_agents: listToText(config.whitelist?.user_agents),
- },
- blacklist: {
- ips: listToText(config.blacklist?.ips),
- ip_cidrs: listToText(config.blacklist?.ip_cidrs),
- paths: listToText(config.blacklist?.paths),
- path_regexes: listToText(config.blacklist?.path_regexes),
- user_agents: listToText(config.blacklist?.user_agents),
- },
- };
-}
-
-function applyDraft(
- config: ProxyRoutePoWConfig,
- draft: PowListDraft,
-): ProxyRoutePoWConfig {
- return {
- ...config,
- whitelist: {
- ips: parseTextareaList(draft.whitelist.ips),
- ip_cidrs: parseTextareaList(draft.whitelist.ip_cidrs),
- paths: parseTextareaList(draft.whitelist.paths),
- path_regexes: parseTextareaList(draft.whitelist.path_regexes),
- user_agents: parseTextareaList(draft.whitelist.user_agents),
- },
- blacklist: {
- ips: parseTextareaList(draft.blacklist.ips),
- ip_cidrs: parseTextareaList(draft.blacklist.ip_cidrs),
- paths: parseTextareaList(draft.blacklist.paths),
- path_regexes: parseTextareaList(draft.blacklist.path_regexes),
- user_agents: parseTextareaList(draft.blacklist.user_agents),
- },
- };
-}
-
-function PowListCard({
- title,
- scope,
- draft,
- onUpdate,
-}: {
- title: string;
- scope: keyof PowListDraft;
- draft: PowListDraft;
- onUpdate: (
- scope: keyof PowListDraft,
- key: keyof ProxyRoutePoWConfig['whitelist'],
- value: string,
- ) => void;
-}) {
- const fields: Array<{
- key: keyof ProxyRoutePoWConfig['whitelist'];
- label: string;
- placeholder: string;
- }> = [
- {key: 'ips', label: 'IP', placeholder: '每行一个 IP'},
- {key: 'ip_cidrs', label: 'IP CIDR', placeholder: '每行一个网段,如 10.0.0.0/8'},
- {key: 'paths', label: '路径', placeholder: '每行一个路径前缀'},
- {key: 'path_regexes', label: '路径正则', placeholder: '每行一个正则表达式'},
- {key: 'user_agents', label: 'User-Agent', placeholder: '每行一个 UA 关键词或正则'},
- ];
-
- return (
-
-
{title}
-
- {fields.map((field) => (
-
-
-
- ))}
-
-
- );
-}
-
-export function PowConfigPanel({enabled, config, onChange}: PowConfigPanelProps) {
- const [draft, setDraft] = useState(() => buildDraft(config));
-
- useEffect(() => {
- setDraft(buildDraft(config));
- }, [config]);
-
- const emitChange = (
- nextEnabled: boolean,
- nextConfig: Partial,
- nextDraft?: PowListDraft,
- ) => {
- const merged = {
- ...config,
- ...nextConfig,
- };
- onChange(nextEnabled, nextDraft ? applyDraft(merged, nextDraft) : merged);
- };
-
- const updateList = (
- scope: keyof PowListDraft,
- key: keyof ProxyRoutePoWConfig['whitelist'],
- value: string,
- ) => {
- const nextDraft: PowListDraft = {
- ...draft,
- [scope]: {
- ...draft[scope],
- [key]: value,
- },
- };
- setDraft(nextDraft);
- emitChange(enabled, {}, nextDraft);
- };
-
- return (
-
-
-
-
-
- 启用后,命中该规则组的请求需要完成浏览器计算挑战。
-
-
-
emitChange(checked, {})}
- />
-
-
-
-
-
-
-
-
-
-
-
- emitChange(enabled, {difficulty: Number(event.target.value) || 1})
- }
- />
-
-
-
-
-
- emitChange(enabled, {session_ttl: Number(event.target.value) || 60})
- }
- />
-
-
-
-
-
- emitChange(enabled, {challenge_ttl: Number(event.target.value) || 30})
- }
- />
-
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/frontend/app/(main)/waf/components/rule-entry-dialog.tsx b/frontend/app/(main)/waf/components/rule-entry-dialog.tsx
deleted file mode 100644
index 6fee5ee0..00000000
--- a/frontend/app/(main)/waf/components/rule-entry-dialog.tsx
+++ /dev/null
@@ -1,367 +0,0 @@
-'use client';
-
-import {useEffect, useMemo, useState} from 'react';
-import {zodResolver} from '@hookform/resolvers/zod';
-import {Search} from 'lucide-react';
-import {useForm} from 'react-hook-form';
-import {z} from 'zod';
-
-import {Button} from '@/components/ui/button';
-import {Checkbox} from '@/components/ui/checkbox';
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog';
-import {Input} from '@/components/ui/input';
-import {Label} from '@/components/ui/label';
-import {Textarea} from '@/components/ui/textarea';
-import {cn} from '@/lib/utils';
-import type {WAFIPGroup} from '@/lib/services/openflare';
-
-import {type CountryOption, normalizeItems, type RuleDimension, type RuleListType, textToList,} from './helpers';
-
-const ruleEntrySchema = z
- .object({
- listType: z.enum(['whitelist', 'blacklist']),
- dimension: z.enum(['ip', 'ip_group', 'country']),
- ipValue: z.string(),
- ipGroupIDs: z.array(z.number()),
- countryValues: z.array(z.string()),
- })
- .superRefine((value, context) => {
- if (value.dimension === 'ip') {
- if (textToList(value.ipValue).length === 0) {
- context.addIssue({
- code: z.ZodIssueCode.custom,
- path: ['ipValue'],
- message: '请输入至少一个 IP 或 IP 段',
- });
- }
- return;
- }
- if (value.dimension === 'ip_group') {
- if (value.ipGroupIDs.length === 0) {
- context.addIssue({
- code: z.ZodIssueCode.custom,
- path: ['ipGroupIDs'],
- message: '请选择至少一个 IP 组',
- });
- }
- return;
- }
- if (normalizeItems(value.countryValues).length === 0) {
- context.addIssue({
- code: z.ZodIssueCode.custom,
- path: ['countryValues'],
- message: '请选择至少一个国家/地区',
- });
- }
- });
-
-export type RuleEntryFormValues = z.infer;
-
-const defaultRuleEntryValues: RuleEntryFormValues = {
- listType: 'blacklist',
- dimension: 'ip',
- ipValue: '',
- ipGroupIDs: [],
- countryValues: [],
-};
-
-interface RuleEntryDialogProps {
- open: boolean;
- countryOptions: CountryOption[];
- ipGroups: WAFIPGroup[];
- onOpenChange: (open: boolean) => void;
- onSubmit: (values: RuleEntryFormValues) => void;
-}
-
-export function RuleEntryDialog({
- open,
- countryOptions,
- ipGroups,
- onOpenChange,
- onSubmit,
-}: RuleEntryDialogProps) {
- const [keyword, setKeyword] = useState('');
- const form = useForm({
- resolver: zodResolver(ruleEntrySchema),
- defaultValues: defaultRuleEntryValues,
- });
-
- const listType = form.watch('listType');
- const dimension = form.watch('dimension');
- const ipValue = form.watch('ipValue');
- const ipGroupIDs = form.watch('ipGroupIDs');
- const countryValues = form.watch('countryValues');
-
- useEffect(() => {
- if (!open) return;
- form.reset(defaultRuleEntryValues);
- setKeyword('');
- }, [form, open]);
-
- useEffect(() => {
- if (!open) return;
- setKeyword('');
- }, [dimension, open]);
-
- const selectedCountrySet = useMemo(
- () => new Set(countryValues),
- [countryValues],
- );
- const selectedIPGroupSet = useMemo(
- () => new Set(ipGroupIDs),
- [ipGroupIDs],
- );
-
- const filteredCountries = useMemo(() => {
- const normalized = keyword.trim().toLowerCase();
- return countryOptions
- .filter((option) => !normalized || option.searchText.includes(normalized))
- .sort((left, right) => {
- const leftSelected = selectedCountrySet.has(left.code) ? 1 : 0;
- const rightSelected = selectedCountrySet.has(right.code) ? 1 : 0;
- return rightSelected - leftSelected || left.code.localeCompare(right.code);
- });
- }, [countryOptions, keyword, selectedCountrySet]);
-
- const toggleCountry = (code: string) => {
- const values = selectedCountrySet.has(code)
- ? countryValues.filter((item) => item !== code)
- : normalizeItems([...countryValues, code]);
- form.setValue('countryValues', values, { shouldValidate: true });
- };
-
- const toggleIPGroup = (id: number) => {
- const values = selectedIPGroupSet.has(id)
- ? ipGroupIDs.filter((item) => item !== id)
- : [...ipGroupIDs, id].sort((left, right) => left - right);
- form.setValue('ipGroupIDs', values, { shouldValidate: true });
- };
-
- const typeLabel = listType === 'blacklist' ? '黑名单' : '白名单';
- const dimensionLabel =
- dimension === 'ip'
- ? 'IP'
- : dimension === 'ip_group'
- ? 'IP 组'
- : '地域';
-
- const handleSubmit = form.handleSubmit((values) => {
- onSubmit(values);
- });
-
- return (
-
- );
-}
diff --git a/frontend/app/(main)/waf/components/rule-list-section.tsx b/frontend/app/(main)/waf/components/rule-list-section.tsx
deleted file mode 100644
index 8d2a1bae..00000000
--- a/frontend/app/(main)/waf/components/rule-list-section.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-'use client';
-
-import {X} from 'lucide-react';
-
-import {Badge} from '@/components/ui/badge';
-import {Button} from '@/components/ui/button';
-
-interface RuleListSectionProps {
- title: string;
- description: string;
- items: string[];
- groupItems?: Array<{ id: number; name: string; enabled: boolean }>;
- tone: 'whitelist' | 'blacklist';
- emptyText: string;
- onRemove: (item: string) => void;
- onRemoveGroup?: (id: number) => void;
-}
-
-export function RuleListSection({
- title,
- description,
- items,
- groupItems = [],
- tone,
- emptyText,
- onRemove,
- onRemoveGroup,
-}: RuleListSectionProps) {
- const total = items.length + groupItems.length;
- const badgeVariant = tone === 'whitelist' ? 'secondary' : 'destructive';
-
- return (
-
-
-
-
{title}
-
{description}
-
-
{total}
-
- {total > 0 ? (
-
- {groupItems.map((group) => (
-
- IP组: {group.name}
- {!group.enabled ? ' (停用)' : ''}
-
-
- ))}
- {items.map((item) => (
-
- {item}
-
-
- ))}
-
- ) : (
-
{emptyText}
- )}
-
- );
-}
diff --git a/frontend/app/(main)/waf/components/site-binding-sheet.tsx b/frontend/app/(main)/waf/components/site-binding-sheet.tsx
index 9232d533..456e393b 100644
--- a/frontend/app/(main)/waf/components/site-binding-sheet.tsx
+++ b/frontend/app/(main)/waf/components/site-binding-sheet.tsx
@@ -54,14 +54,14 @@ export function SiteBindingSheet({
setSelectedIDs((current) =>
current.includes(id)
? current.filter((item) => item !== id)
- : [...current, id].sort((left, right) => left - right),
+ : [...current, id],
);
};
const selectFiltered = () => {
const next = new Set(selectedIDs);
filteredRoutes.forEach((route) => next.add(route.id));
- setSelectedIDs([...next].sort((left, right) => left - right));
+ setSelectedIDs([...next]);
};
return (
diff --git a/frontend/tests/unit/waf-binding-order.test.tsx b/frontend/tests/unit/waf-binding-order.test.tsx
new file mode 100644
index 00000000..9ed5cee1
--- /dev/null
+++ b/frontend/tests/unit/waf-binding-order.test.tsx
@@ -0,0 +1,103 @@
+import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
+import {fireEvent, render, screen, waitFor} from '@testing-library/react';
+import {createElement} from 'react';
+import {beforeEach, describe, expect, it, vi} from 'vitest';
+
+import {
+ reorderRuleIDs,
+ WafSection,
+} from '@/app/(main)/proxy-routes/detail/components/waf-section';
+import type {ProxyRouteItem, WAFRule} from '@/lib/services/openflare';
+import {WafService} from '@/lib/services/openflare';
+
+vi.mock('@/lib/services/openflare', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ WafService: {
+ listSiteRuleGroups: vi.fn(),
+ updateSiteRuleGroups: vi.fn(),
+ },
+ };
+});
+
+const graph = {
+ schema_version: 1,
+ nodes: [
+ {id: 'start', type: 'start' as const, position: {x: 0, y: 0}, config: {}},
+ {id: 'allow', type: 'allow' as const, position: {x: 100, y: 0}, config: {}},
+ ],
+ edges: [{id: 'edge', source: 'start', source_handle: 'next', target: 'allow'}],
+};
+
+function rule(id: number, name: string, isGlobal = false): WAFRule {
+ return {
+ id,
+ name,
+ enabled: true,
+ is_global: isGlobal,
+ graph,
+ revision: 1,
+ applied_site_ids: [],
+ applied_site_count: 0,
+ created_at: '',
+ updated_at: '',
+ };
+}
+
+describe('WAF route binding order', () => {
+ beforeEach(() => {
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ },
+ );
+ vi.mocked(WafService.listSiteRuleGroups).mockReset();
+ vi.mocked(WafService.updateSiteRuleGroups).mockReset();
+ vi.mocked(WafService.listSiteRuleGroups).mockResolvedValue({
+ route_id: 9,
+ global_rule_group: rule(99, '全局规则', true),
+ rule_groups: [rule(1, '规则 A'), rule(2, '规则 B'), rule(3, '规则 C')],
+ applied_rule_groups: [rule(1, '规则 A'), rule(2, '规则 B')],
+ applied_ids: [1, 2],
+ });
+ vi.mocked(WafService.updateSiteRuleGroups).mockImplementation(async (_routeID, ids) => ({
+ route_id: 9,
+ global_rule_group: rule(99, '全局规则', true),
+ rule_groups: [rule(1, '规则 A'), rule(2, '规则 B'), rule(3, '规则 C')],
+ applied_rule_groups: ids.map((id) => rule(id, `规则 ${id}`)),
+ applied_ids: ids,
+ }));
+ });
+
+ it('reorders by the active and target IDs reported by drag end', () => {
+ expect(reorderRuleIDs([1, 2, 3], 3, 1)).toEqual([3, 1, 2]);
+ });
+
+ it('keeps the global rule fixed and submits custom rules in UI order', async () => {
+ const client = new QueryClient({defaultOptions: {queries: {retry: false}}});
+ render(
+ createElement(
+ QueryClientProvider,
+ {client},
+ createElement(WafSection, {route: {id: 9} as ProxyRouteItem}),
+ ),
+ );
+
+ expect(await screen.findByText('全局规则')).toBeInTheDocument();
+ expect(screen.getByText('始终生效')).toBeInTheDocument();
+ expect(screen.queryByRole('button', {name: '上移全局规则'})).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('checkbox', {name: '选择规则 C'}));
+ fireEvent.click(screen.getByRole('button', {name: '上移规则 C'}));
+ fireEvent.click(screen.getByRole('button', {name: '上移规则 C'}));
+ fireEvent.click(screen.getByRole('button', {name: '保存'}));
+
+ await waitFor(() => {
+ expect(WafService.updateSiteRuleGroups).toHaveBeenCalledWith(9, [3, 1, 2]);
+ });
+ });
+});