mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 15:25:01 +03:00
feat(amneziawg): support persistent keepalive ranges (#6881)
AmneziaWG clients accept a randomised PersistentKeepalive range such as 15-25, which makes keepalive timing harder to fingerprint; the panel only stored a single number. A client's keepalive may now be a range, validated the way AmneziaWG parses it. The downloadable .conf and vpn:// profile carry the range, Clash/Mihomo output gets its lower bound, and plain WireGuard stays scalar-only. The range lives in an additive wg_keep_alive_range column, so the integer column and an older panel binary keep working after a rollback.
This commit is contained in:
@@ -1759,9 +1759,15 @@
|
||||
"type": "string"
|
||||
},
|
||||
"keepAlive": {
|
||||
"description": "Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value",
|
||||
"nullable": true,
|
||||
"type": "integer"
|
||||
"description": "Numeric values or AmneziaWG ranges; 0 disables, omission preserves.",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"limitIp": {
|
||||
"description": "IP limit for this client",
|
||||
@@ -1981,7 +1987,15 @@
|
||||
"type": "integer"
|
||||
},
|
||||
"keepAlive": {
|
||||
"type": "integer"
|
||||
"description": "Numeric values or AmneziaWG ranges.",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"limitHwid": {
|
||||
"type": "integer"
|
||||
|
||||
@@ -424,7 +424,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"forwardedPorts": "",
|
||||
"group": "",
|
||||
"id": "",
|
||||
"keepAlive": null,
|
||||
"keepAlive": 0,
|
||||
"limitIp": 0,
|
||||
"password": "",
|
||||
"preSharedKey": "",
|
||||
|
||||
@@ -1733,9 +1733,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "string"
|
||||
},
|
||||
"keepAlive": {
|
||||
"description": "Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value",
|
||||
"nullable": true,
|
||||
"type": "integer"
|
||||
"description": "Numeric values or AmneziaWG ranges; 0 disables, omission preserves.",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"limitIp": {
|
||||
"description": "IP limit for this client",
|
||||
@@ -1955,7 +1961,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "integer"
|
||||
},
|
||||
"keepAlive": {
|
||||
"type": "integer"
|
||||
"description": "Numeric values or AmneziaWG ranges.",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"limitHwid": {
|
||||
"type": "integer"
|
||||
|
||||
@@ -420,7 +420,7 @@ export interface Client {
|
||||
forwardedPorts?: string;
|
||||
group?: string;
|
||||
id?: string;
|
||||
keepAlive?: number | null;
|
||||
keepAlive?: number | string;
|
||||
limitIp: number;
|
||||
password?: string;
|
||||
preSharedKey?: string;
|
||||
@@ -471,7 +471,7 @@ export interface ClientRecord {
|
||||
forwardedPorts: string;
|
||||
group: string;
|
||||
id: number;
|
||||
keepAlive: number;
|
||||
keepAlive: number | string;
|
||||
limitHwid: number;
|
||||
limitIp: number;
|
||||
password: string;
|
||||
|
||||
@@ -441,7 +441,7 @@ export const ClientSchema = z.object({
|
||||
forwardedPorts: z.string().optional(),
|
||||
group: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
keepAlive: z.number().int().nullable().optional(),
|
||||
keepAlive: z.union([z.number().int(), z.string()]).optional(),
|
||||
limitIp: z.number().int(),
|
||||
password: z.string().optional(),
|
||||
preSharedKey: z.string().optional(),
|
||||
@@ -495,7 +495,7 @@ export const ClientRecordSchema = z.object({
|
||||
forwardedPorts: z.string(),
|
||||
group: z.string(),
|
||||
id: z.number().int(),
|
||||
keepAlive: z.number().int(),
|
||||
keepAlive: z.union([z.number().int(), z.string()]),
|
||||
limitHwid: z.number().int(),
|
||||
limitIp: z.number().int(),
|
||||
password: z.string(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Base64, Wireguard } from '@/utils';
|
||||
import { effectiveMtu } from '@/lib/xray/amneziawg-obfuscation';
|
||||
import { AmneziaWGKeepAliveRangeSchema } from '@/schemas/primitives/keepalive';
|
||||
|
||||
import type { Inbound } from '@/schemas/api/inbound';
|
||||
import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
|
||||
@@ -1127,7 +1128,10 @@ export function genAmneziaWGConfig(input: GenAmneziaWGLinkInput): string {
|
||||
lines.push(`PresharedKey = ${client.preSharedKey}`);
|
||||
}
|
||||
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${address}:${port}`);
|
||||
if (typeof client.keepAlive === 'number' && client.keepAlive > 0) {
|
||||
if (typeof client.keepAlive === 'string') {
|
||||
if (!AmneziaWGKeepAliveRangeSchema.safeParse(client.keepAlive).success) return '';
|
||||
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
|
||||
} else if (typeof client.keepAlive === 'number' && client.keepAlive > 0) {
|
||||
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
|
||||
}
|
||||
return joinConfLines(lines);
|
||||
|
||||
@@ -38,9 +38,14 @@ import { resolveExternalLinkExpiry } from '@/lib/clients/external-link';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import { useClientHwids } from '@/hooks/useClientHwids';
|
||||
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import ClientHwidListModal from '@/components/clients/ClientHwidList';
|
||||
import { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives';
|
||||
import {
|
||||
OptionalAmneziaWGKeepAliveInputSchema,
|
||||
OptionalNumericKeepAliveInputSchema,
|
||||
keepAliveInputValue,
|
||||
} from '@/schemas/primitives/keepalive';
|
||||
import type {
|
||||
ClientRecord,
|
||||
InboundOption,
|
||||
@@ -137,7 +142,7 @@ type Values = ClientFormValues & {
|
||||
wgAllowedIPs: string;
|
||||
awgAllowedIPs: string;
|
||||
awgForwardedPorts: string;
|
||||
wgKeepAlive: number;
|
||||
wgKeepAlive: number | string | undefined;
|
||||
secret: string;
|
||||
adTag: string;
|
||||
};
|
||||
@@ -389,7 +394,7 @@ export default function ClientFormModal({
|
||||
wgAllowedIPs: wgTunnelIPs ?? client.allowedIPs ?? '',
|
||||
awgAllowedIPs: awgTunnelIPs ?? client.allowedIPs ?? '',
|
||||
awgForwardedPorts: client.forwardedPorts || '',
|
||||
wgKeepAlive: client.keepAlive ?? 0,
|
||||
wgKeepAlive: client.keepAlive,
|
||||
secret: client.secret || '',
|
||||
adTag: client.adTag || '',
|
||||
};
|
||||
@@ -641,6 +646,25 @@ export default function ClientFormModal({
|
||||
|
||||
async function onSubmit() {
|
||||
const values = methods.getValues();
|
||||
const unchangedLegacyKeepAlive =
|
||||
isEdit &&
|
||||
typeof client?.keepAlive === 'number' &&
|
||||
client.keepAlive > 65535 &&
|
||||
values.wgKeepAlive === client.keepAlive;
|
||||
if (showAmneziawg && !unchangedLegacyKeepAlive) {
|
||||
const keepAliveSchema = showWireguard
|
||||
? OptionalNumericKeepAliveInputSchema
|
||||
: OptionalAmneziaWGKeepAliveInputSchema;
|
||||
const keepAliveResult = keepAliveSchema.safeParse(values.wgKeepAlive);
|
||||
if (!keepAliveResult.success) {
|
||||
methods.setError('wgKeepAlive', {
|
||||
type: 'validate',
|
||||
message: keepAliveResult.error.issues[0]?.message ?? 'validation.invalid',
|
||||
});
|
||||
return;
|
||||
}
|
||||
methods.clearErrors('wgKeepAlive');
|
||||
}
|
||||
const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema;
|
||||
const validated = schema.safeParse({
|
||||
email: values.email,
|
||||
@@ -712,7 +736,9 @@ export default function ClientFormModal({
|
||||
// so both protocols share this one field set — see wgPrivateKey etc.
|
||||
// below and the AmneziaWG-labeled variants of the same inputs.
|
||||
clientPayload.privateKey = values.wgPrivateKey;
|
||||
clientPayload.keepAlive = values.wgKeepAlive;
|
||||
if (!unchangedLegacyKeepAlive && values.wgKeepAlive !== undefined) {
|
||||
clientPayload.keepAlive = values.wgKeepAlive;
|
||||
}
|
||||
clientPayload.publicKey = values.wgPublicKey;
|
||||
if (values.wgPreSharedKey) {
|
||||
clientPayload.preSharedKey = values.wgPreSharedKey;
|
||||
@@ -1339,10 +1365,34 @@ export default function ClientFormModal({
|
||||
<FormField
|
||||
name="wgKeepAlive"
|
||||
label={t('pages.clients.tunnelKeepAlive')}
|
||||
extra={t('pages.clients.tunnelKeepAliveHint')}
|
||||
transform={{ output: (v) => Number(v) || 0 }}
|
||||
extra={t(
|
||||
showAmneziawg && !showWireguard
|
||||
? 'pages.clients.amneziaWgKeepAliveHint'
|
||||
: 'pages.clients.tunnelKeepAliveHint',
|
||||
)}
|
||||
transform={{
|
||||
output:
|
||||
showAmneziawg && !showWireguard
|
||||
? keepAliveInputValue
|
||||
: (value) => Number(value) || 0,
|
||||
}}
|
||||
rules={
|
||||
showAmneziawg
|
||||
? {
|
||||
validate: rhfZodValidate(
|
||||
showWireguard
|
||||
? OptionalNumericKeepAliveInputSchema
|
||||
: OptionalAmneziaWGKeepAliveInputSchema,
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
|
||||
{showAmneziawg && !showWireguard ? (
|
||||
<Input placeholder="25" />
|
||||
) : (
|
||||
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
|
||||
)}
|
||||
</FormField>
|
||||
{showAmneziawg && (
|
||||
<FormField
|
||||
|
||||
@@ -3,6 +3,7 @@ import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
|
||||
import { effectiveMtu } from '@/lib/xray/amneziawg-obfuscation';
|
||||
import { joinConfLines } from '@/lib/xray/conf-lines';
|
||||
import { AmneziaWGKeepAliveRangeSchema } from '@/schemas/primitives/keepalive';
|
||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||
|
||||
// AmneziaWG clients are wire-identical to WireGuard clients (same
|
||||
@@ -17,7 +18,7 @@ export function isAmneziaWGClient(client: ClientRecord | null | undefined): bool
|
||||
client.publicKey ||
|
||||
client.allowedIPs ||
|
||||
client.preSharedKey ||
|
||||
client.keepAlive
|
||||
client.keepAlive !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,7 +103,11 @@ export function buildAmneziaWGClientConfig(
|
||||
lines.push('[Peer]', `PublicKey = ${server?.publicKey || ''}`);
|
||||
if (client.preSharedKey) lines.push(`PresharedKey = ${client.preSharedKey}`);
|
||||
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${endpoint}`);
|
||||
if (client.keepAlive && client.keepAlive > 0)
|
||||
if (typeof client.keepAlive === 'string') {
|
||||
if (!AmneziaWGKeepAliveRangeSchema.safeParse(client.keepAlive).success) return '';
|
||||
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
|
||||
} else if (client.keepAlive && client.keepAlive > 0) {
|
||||
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
|
||||
}
|
||||
return joinConfLines(lines);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export function isWireguardClient(client: ClientRecord | null | undefined): bool
|
||||
client.publicKey ||
|
||||
client.allowedIPs ||
|
||||
client.preSharedKey ||
|
||||
client.keepAlive
|
||||
typeof client.keepAlive === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function buildWireguardClientConfig(
|
||||
lines.push('[Peer]', `PublicKey = ${inbound?.wgPublicKey || ''}`);
|
||||
if (client.preSharedKey) lines.push(`PresharedKey = ${client.preSharedKey}`);
|
||||
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${endpoint}`);
|
||||
if (client.keepAlive && client.keepAlive > 0)
|
||||
if (typeof client.keepAlive === 'number' && client.keepAlive > 0)
|
||||
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
|
||||
return joinConfLines(lines);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { OptionalClientKeepAliveSchema } from '@/schemas/primitives/keepalive';
|
||||
|
||||
const nullableStringArray = z
|
||||
.array(z.string())
|
||||
@@ -52,7 +53,7 @@ export const ClientRecordSchema = z
|
||||
publicKey: z.string().optional(),
|
||||
allowedIPs: z.string().optional(),
|
||||
preSharedKey: z.string().optional(),
|
||||
keepAlive: z.number().optional(),
|
||||
keepAlive: OptionalClientKeepAliveSchema,
|
||||
forwardedPorts: z.string().optional(),
|
||||
secret: z.string().optional(),
|
||||
adTag: z.string().optional(),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const invalidKeepAlive = 'pages.clients.amneziaWgKeepAliveInvalid';
|
||||
const MAX_KEEPALIVE_SECONDS = 65535;
|
||||
|
||||
export const AmneziaWGKeepAliveRangeSchema = z.string().refine((value) => {
|
||||
if (!/^(0|[1-9]\d*)-(0|[1-9]\d*)$/.test(value)) return false;
|
||||
const [minimum, maximum] = value.split('-').map(Number);
|
||||
return minimum >= 0 && minimum <= maximum && maximum <= MAX_KEEPALIVE_SECONDS;
|
||||
}, invalidKeepAlive);
|
||||
|
||||
// Numeric values stay permissive here so older stored values remain readable.
|
||||
export const ClientKeepAliveSchema = z.union([z.number(), AmneziaWGKeepAliveRangeSchema]);
|
||||
export const OptionalClientKeepAliveSchema = z.preprocess(
|
||||
(value) => (value == null ? undefined : value),
|
||||
ClientKeepAliveSchema.optional(),
|
||||
);
|
||||
|
||||
const boundedSeconds = z
|
||||
.number()
|
||||
.refine(
|
||||
(value) => Number.isInteger(value) && value >= 0 && value <= MAX_KEEPALIVE_SECONDS,
|
||||
invalidKeepAlive,
|
||||
);
|
||||
|
||||
export const OptionalNumericKeepAliveInputSchema = z.preprocess(
|
||||
(value) => (value == null || value === '' ? undefined : value),
|
||||
boundedSeconds.optional(),
|
||||
);
|
||||
|
||||
export const OptionalAmneziaWGKeepAliveInputSchema = z.preprocess(
|
||||
(value) => (value == null || value === '' ? undefined : value),
|
||||
z.union([boundedSeconds, AmneziaWGKeepAliveRangeSchema]).optional(),
|
||||
);
|
||||
|
||||
export function keepAliveInputValue(value: unknown): number | string | undefined {
|
||||
if (value == null || value === '') return undefined;
|
||||
const text = String(value);
|
||||
return /^\d+$/.test(text) ? Number(text) : text;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { OptionalClientKeepAliveSchema } from '@/schemas/primitives/keepalive';
|
||||
|
||||
// AntD InputNumber emits null (not undefined) when the user clears it, and
|
||||
// the form store hands that null straight to safeParse on submit — a bare
|
||||
@@ -23,7 +24,7 @@ export const AmneziawgClientSchema = z.object({
|
||||
publicKey: z.string().optional(),
|
||||
preSharedKey: z.string().optional(),
|
||||
allowedIPs: z.array(z.string()).default([]),
|
||||
keepAlive: optionalClearedInt(z.number().int().min(0)),
|
||||
keepAlive: OptionalClientKeepAliveSchema,
|
||||
forwardedPorts: z.string().default(''),
|
||||
email: z.string().min(1),
|
||||
limitIp: z.number().int().min(0).default(0),
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import { buildAmneziaWGClientConfig, isAmneziaWGClient } from '@/pages/clients/amneziawgConfig';
|
||||
import { genAmneziaWGConfig } from '@/lib/xray/inbound-link';
|
||||
import { buildWireguardClientConfig, isWireguardClient } from '@/pages/clients/wireguardConfig';
|
||||
import ClientFormModal from '@/pages/clients/ClientFormModal';
|
||||
import { ClientRecordSchema } from '@/schemas/client';
|
||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||
import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
|
||||
import {
|
||||
OptionalAmneziaWGKeepAliveInputSchema,
|
||||
OptionalNumericKeepAliveInputSchema,
|
||||
keepAliveInputValue,
|
||||
} from '@/schemas/primitives/keepalive';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const awgInbound = {
|
||||
id: 17,
|
||||
tag: 'awg-17',
|
||||
protocol: 'amneziawg',
|
||||
port: 51820,
|
||||
awgServer: { publicKey: 'serverPubKey==', jc: 4, jmin: 40, jmax: 100, s1: 30, s2: 90 },
|
||||
} as unknown as InboundOption;
|
||||
|
||||
const baseClient = {
|
||||
email: 'peer-1',
|
||||
privateKey: 'clientPrivKey==',
|
||||
allowedIPs: '10.8.1.2/32',
|
||||
keepAlive: 25,
|
||||
} as unknown as ClientRecord;
|
||||
|
||||
describe('AWG keepalive range UI contract', () => {
|
||||
it('accepts range-valued API records while preserving legacy numeric values', () => {
|
||||
expect(ClientRecordSchema.parse({ email: 'peer-1', keepAlive: '15-25' }).keepAlive).toBe(
|
||||
'15-25',
|
||||
);
|
||||
expect(ClientRecordSchema.parse({ email: 'legacy', keepAlive: 70000 }).keepAlive).toBe(70000);
|
||||
expect(ClientRecordSchema.safeParse({ email: 'bad', keepAlive: '15-65536' }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(ClientRecordSchema.safeParse({ email: 'bad', keepAlive: '015-25' }).success).toBe(false);
|
||||
expect(ClientRecordSchema.safeParse({ email: 'bad', keepAlive: '25-15' }).success).toBe(false);
|
||||
expect(ClientRecordSchema.parse({ email: 'peer-1', keepAlive: '0-65535' }).keepAlive).toBe(
|
||||
'0-65535',
|
||||
);
|
||||
expect(OptionalNumericKeepAliveInputSchema.safeParse('15-25').success).toBe(false);
|
||||
expect(OptionalAmneziaWGKeepAliveInputSchema.parse(keepAliveInputValue('0'))).toBe(0);
|
||||
expect(OptionalAmneziaWGKeepAliveInputSchema.parse(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('writes the exact range to a native AmneziaWG profile', () => {
|
||||
const conf = buildAmneziaWGClientConfig(
|
||||
{ ...baseClient, keepAlive: '15-25' } as unknown as ClientRecord,
|
||||
awgInbound,
|
||||
'awg.example.test',
|
||||
);
|
||||
expect(conf).toContain('PersistentKeepalive = 15-25');
|
||||
});
|
||||
|
||||
it('writes the exact range to an AmneziaWG share profile', () => {
|
||||
const settings = {
|
||||
server: { publicKey: 'serverPubKey==', jc: 4, jmin: 40, jmax: 100, s1: 30, s2: 90 },
|
||||
clients: [
|
||||
{
|
||||
email: 'peer-1',
|
||||
privateKey: 'clientPrivKey==',
|
||||
allowedIPs: ['10.8.1.2/32'],
|
||||
keepAlive: '15-25',
|
||||
},
|
||||
],
|
||||
} as unknown as AmneziawgInboundSettings;
|
||||
const conf = genAmneziaWGConfig({
|
||||
settings,
|
||||
address: 'awg.example.test',
|
||||
port: 51820,
|
||||
remark: 'peer-1',
|
||||
peerIndex: 0,
|
||||
});
|
||||
expect(conf).toContain('PersistentKeepalive = 15-25');
|
||||
});
|
||||
|
||||
it('never exports a range into a plain WireGuard profile', () => {
|
||||
const conf = buildWireguardClientConfig(
|
||||
{ ...baseClient, keepAlive: '15-25' } as unknown as ClientRecord,
|
||||
{ id: 18, tag: 'wg-18', protocol: 'wireguard', port: 51821 } as unknown as InboundOption,
|
||||
'wg.example.test',
|
||||
);
|
||||
expect(conf).toContain('PrivateKey = clientPrivKey==');
|
||||
expect(conf).toContain('Endpoint = wg.example.test:51821');
|
||||
expect(conf).not.toContain('PersistentKeepalive');
|
||||
const rangeOnlyClient = { email: 'peer-1', keepAlive: '15-25' } as unknown as ClientRecord;
|
||||
expect(isWireguardClient(rangeOnlyClient)).toBe(false);
|
||||
expect(isAmneziaWGClient(rangeOnlyClient)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the shared WireGuard and AWG form numeric-only', async () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
renderWithProviders(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ClientFormModal
|
||||
open
|
||||
mode="edit"
|
||||
client={baseClient}
|
||||
inbounds={[
|
||||
awgInbound,
|
||||
{
|
||||
id: 18,
|
||||
tag: 'wg-18',
|
||||
protocol: 'wireguard',
|
||||
port: 51821,
|
||||
} as unknown as InboundOption,
|
||||
]}
|
||||
attachedIds={[17, 18]}
|
||||
save={vi.fn().mockResolvedValue({ success: true })}
|
||||
onOpenChange={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const credentialsTab = Array.from(document.querySelectorAll('.ant-tabs-tab')).find(
|
||||
(tab) => tab.textContent?.trim() === 'Credentials',
|
||||
);
|
||||
if (!credentialsTab) throw new Error('Credentials tab not found');
|
||||
fireEvent.click(credentialsTab);
|
||||
|
||||
const keepAliveLabel = Array.from(
|
||||
document.querySelectorAll<HTMLLabelElement>('.ant-form-item-label label'),
|
||||
).find((label) => label.textContent?.trim() === 'Keepalive (seconds)');
|
||||
if (!keepAliveLabel?.htmlFor) throw new Error('Keepalive field label not found');
|
||||
const keepAlive = await screen.findByRole('spinbutton', { name: 'Keepalive (seconds)' });
|
||||
expect(document.getElementById(keepAliveLabel.htmlFor)).toBe(keepAlive);
|
||||
expect(screen.queryByPlaceholderText('25')).toBeNull();
|
||||
});
|
||||
|
||||
it('submits an AWG range from the client form', async () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const save = vi.fn().mockResolvedValue({ success: true });
|
||||
renderWithProviders(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ClientFormModal
|
||||
open
|
||||
mode="edit"
|
||||
client={baseClient}
|
||||
inbounds={[awgInbound]}
|
||||
attachedIds={[17]}
|
||||
save={save}
|
||||
onOpenChange={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const credentialsTab = Array.from(document.querySelectorAll('.ant-tabs-tab')).find(
|
||||
(tab) => tab.textContent?.trim() === 'Credentials',
|
||||
);
|
||||
if (!credentialsTab) throw new Error('Credentials tab not found');
|
||||
fireEvent.click(credentialsTab);
|
||||
|
||||
const keepAlive = await screen.findByPlaceholderText('25');
|
||||
fireEvent.change(keepAlive, { target: { value: '15-25' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
await waitFor(() => expect(save).toHaveBeenCalled());
|
||||
expect((save.mock.calls[0][0] as Record<string, unknown>).keepAlive).toBe('15-25');
|
||||
});
|
||||
|
||||
it('preserves an unchanged legacy numeric value outside the new range', async () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const save = vi.fn().mockResolvedValue({ success: true });
|
||||
renderWithProviders(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ClientFormModal
|
||||
open
|
||||
mode="edit"
|
||||
client={{ ...baseClient, keepAlive: 70000 } as unknown as ClientRecord}
|
||||
inbounds={[awgInbound]}
|
||||
attachedIds={[17]}
|
||||
save={save}
|
||||
onOpenChange={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const credentialsTab = Array.from(document.querySelectorAll('.ant-tabs-tab')).find(
|
||||
(tab) => tab.textContent?.trim() === 'Credentials',
|
||||
);
|
||||
if (!credentialsTab) throw new Error('Credentials tab not found');
|
||||
fireEvent.click(credentialsTab);
|
||||
await screen.findByPlaceholderText('25');
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
await waitFor(() => expect(save).toHaveBeenCalled());
|
||||
expect(Object.hasOwn(save.mock.calls[0][0] as object, 'keepAlive')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects out-of-bounds ranges before saving', async () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const save = vi.fn().mockResolvedValue({ success: true });
|
||||
renderWithProviders(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ClientFormModal
|
||||
open
|
||||
mode="edit"
|
||||
client={baseClient}
|
||||
inbounds={[awgInbound]}
|
||||
attachedIds={[17]}
|
||||
save={save}
|
||||
onOpenChange={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const credentialsTab = Array.from(document.querySelectorAll('.ant-tabs-tab')).find(
|
||||
(tab) => tab.textContent?.trim() === 'Credentials',
|
||||
);
|
||||
if (!credentialsTab) throw new Error('Credentials tab not found');
|
||||
fireEvent.click(credentialsTab);
|
||||
|
||||
const keepAlive = await screen.findByPlaceholderText('25');
|
||||
fireEvent.change(keepAlive, { target: { value: '15-65536' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector('.ant-form-item-explain-error')?.textContent).toContain(
|
||||
'Enter a whole number or range from 0 to 65535.',
|
||||
),
|
||||
);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('omits a cleared AWG value so editing another field preserves the stored value', async () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const save = vi.fn().mockResolvedValue({ success: true });
|
||||
renderWithProviders(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ClientFormModal
|
||||
open
|
||||
mode="edit"
|
||||
client={baseClient}
|
||||
inbounds={[awgInbound]}
|
||||
attachedIds={[17]}
|
||||
save={save}
|
||||
onOpenChange={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const credentialsTab = Array.from(document.querySelectorAll('.ant-tabs-tab')).find(
|
||||
(tab) => tab.textContent?.trim() === 'Credentials',
|
||||
);
|
||||
if (!credentialsTab) throw new Error('Credentials tab not found');
|
||||
fireEvent.click(credentialsTab);
|
||||
const keepAlive = await screen.findByPlaceholderText('25');
|
||||
fireEvent.change(keepAlive, { target: { value: '' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
await waitFor(() => expect(save).toHaveBeenCalled());
|
||||
expect(Object.hasOwn(save.mock.calls[0][0] as object, 'keepAlive')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user