mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 15:25:01 +03:00
feat(clients): bind host rules to individual clients in subscriptions (#6620)
Host rules applied per inbound only, so every client of an inbound got the same subscription endpoints (#6618). A client can now be bound to one or more host rules, and its raw, JSON and Clash subscriptions render only those hosts, in either subscription order; an unbound client is unchanged. A binding whose hosts are deleted, disabled or excluded from the format fails closed: the client gets no links there but stays in traffic accounting. Host edits keep row ids, so bindings survive them; nil leaves a stored binding alone and an empty list clears it.
This commit is contained in:
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.{ts,tsx}": [
|
||||
"oxfmt",
|
||||
"oxfmt --no-error-on-unmatched-pattern",
|
||||
"oxlint --fix"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1730,6 +1730,17 @@
|
||||
"description": "Auth password (Hysteria)",
|
||||
"type": "string"
|
||||
},
|
||||
"clientHostRuleIds": {
|
||||
"description": "ClientHostRuleIds binds the client to these host rules, one link each; nil preserves, empty\nclears, and bindings with no usable host leave that inbound out of the subscription.",
|
||||
"example": [
|
||||
1,
|
||||
2
|
||||
],
|
||||
"items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"comment": {
|
||||
"description": "Client comment",
|
||||
"type": "string"
|
||||
@@ -1967,6 +1978,16 @@
|
||||
"auth": {
|
||||
"type": "string"
|
||||
},
|
||||
"clientHostRuleIds": {
|
||||
"example": [
|
||||
1,
|
||||
2
|
||||
],
|
||||
"items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"comment": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12061,6 +12082,94 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/hosts/raw": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Hosts"
|
||||
],
|
||||
"summary": "List every host row with its numeric id, for id-based pickers such as the per-client host rule override.",
|
||||
"operationId": "get_panel_api_hosts_raw",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Host"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"obj": [
|
||||
{
|
||||
"address": "cdn.example.com",
|
||||
"allowInsecure": false,
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"createdAt": 0,
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
""
|
||||
],
|
||||
"finalMask": "",
|
||||
"fingerprint": "",
|
||||
"groupId": "",
|
||||
"hostHeader": "",
|
||||
"id": 1,
|
||||
"inboundId": 1,
|
||||
"isDisabled": false,
|
||||
"isHidden": false,
|
||||
"keepSniBlank": false,
|
||||
"mihomoIpVersion": "dual",
|
||||
"mihomoX25519": false,
|
||||
"muxParams": null,
|
||||
"nodeGuids": [
|
||||
""
|
||||
],
|
||||
"overrideSniFromAddress": false,
|
||||
"path": "",
|
||||
"pinnedPeerCertSha256": [
|
||||
""
|
||||
],
|
||||
"port": 8443,
|
||||
"randomCount": 1,
|
||||
"remark": "cdn-front",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
"shuffleHost": false,
|
||||
"sni": "",
|
||||
"sockoptParams": null,
|
||||
"sortOrder": 0,
|
||||
"tags": [
|
||||
""
|
||||
],
|
||||
"updatedAt": 0,
|
||||
"verifyPeerCertByName": "",
|
||||
"vlessRoute": "443"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/hosts/get/{groupId}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { parseMsg } from '@/utils/zodValidate';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
|
||||
// One row of the raw hosts table (GET /panel/api/hosts/raw), carrying the
|
||||
// numeric id that per-client host rule bindings reference.
|
||||
export const RawHostSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
inboundId: z.number().optional(),
|
||||
remark: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
port: z.number().optional(),
|
||||
isDisabled: z.boolean().optional(),
|
||||
})
|
||||
.loose();
|
||||
|
||||
export type RawHost = z.infer<typeof RawHostSchema>;
|
||||
|
||||
const RawHostListSchema = z
|
||||
.array(RawHostSchema)
|
||||
.nullable()
|
||||
.transform((v) => v ?? []);
|
||||
|
||||
async function fetchRawHosts(): Promise<RawHost[]> {
|
||||
const msg = await HttpUtil.get('/panel/api/hosts/raw', undefined, { silent: true });
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch hosts');
|
||||
const validated = parseMsg(msg, RawHostListSchema, 'hosts/raw');
|
||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
||||
}
|
||||
|
||||
export function useRawHostsQuery() {
|
||||
const query = useQuery({
|
||||
queryKey: keys.hosts.raw(),
|
||||
queryFn: fetchRawHosts,
|
||||
});
|
||||
|
||||
const hosts = useMemo(() => query.data ?? [], [query.data]);
|
||||
|
||||
return {
|
||||
hosts,
|
||||
loading: query.isFetching,
|
||||
fetched: query.data !== undefined || query.isError,
|
||||
fetchError: query.error ? (query.error as Error).message : '',
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export const keys = {
|
||||
hosts: {
|
||||
root: () => ['hosts'] as const,
|
||||
list: () => ['hosts', 'list'] as const,
|
||||
raw: () => ['hosts', 'raw'] as const,
|
||||
byInbound: (inboundId: number) => ['hosts', 'byInbound', inboundId] as const,
|
||||
tags: () => ['hosts', 'tags'] as const,
|
||||
},
|
||||
|
||||
@@ -417,6 +417,10 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
],
|
||||
"allowedIPsByInbound": {},
|
||||
"auth": "",
|
||||
"clientHostRuleIds": [
|
||||
1,
|
||||
2
|
||||
],
|
||||
"comment": "",
|
||||
"created_at": 0,
|
||||
"email": "",
|
||||
@@ -506,6 +510,10 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"adTag": "",
|
||||
"allowedIPs": "",
|
||||
"auth": "",
|
||||
"clientHostRuleIds": [
|
||||
1,
|
||||
2
|
||||
],
|
||||
"comment": "",
|
||||
"createdAt": 0,
|
||||
"email": "",
|
||||
|
||||
@@ -1704,6 +1704,17 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"description": "Auth password (Hysteria)",
|
||||
"type": "string"
|
||||
},
|
||||
"clientHostRuleIds": {
|
||||
"description": "ClientHostRuleIds binds the client to these host rules, one link each; nil preserves, empty\nclears, and bindings with no usable host leave that inbound out of the subscription.",
|
||||
"example": [
|
||||
1,
|
||||
2
|
||||
],
|
||||
"items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"comment": {
|
||||
"description": "Client comment",
|
||||
"type": "string"
|
||||
@@ -1941,6 +1952,16 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"auth": {
|
||||
"type": "string"
|
||||
},
|
||||
"clientHostRuleIds": {
|
||||
"example": [
|
||||
1,
|
||||
2
|
||||
],
|
||||
"items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"comment": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -413,6 +413,7 @@ export interface Client {
|
||||
allowedIPs?: string[];
|
||||
allowedIPsByInbound?: Record<number, string[]>;
|
||||
auth?: string;
|
||||
clientHostRuleIds?: number[];
|
||||
comment: string;
|
||||
created_at?: number;
|
||||
email: string;
|
||||
@@ -464,6 +465,7 @@ export interface ClientRecord {
|
||||
adTag: string;
|
||||
allowedIPs: string;
|
||||
auth: string;
|
||||
clientHostRuleIds?: number[];
|
||||
comment: string;
|
||||
createdAt: number;
|
||||
email: string;
|
||||
|
||||
@@ -434,6 +434,7 @@ export const ClientSchema = z.object({
|
||||
allowedIPs: z.array(z.string()).optional(),
|
||||
allowedIPsByInbound: z.record(z.number().int(), z.array(z.string())).optional(),
|
||||
auth: z.string().optional(),
|
||||
clientHostRuleIds: z.array(z.number().int()).optional(),
|
||||
comment: z.string(),
|
||||
created_at: z.number().int().optional(),
|
||||
email: z.string(),
|
||||
@@ -488,6 +489,7 @@ export const ClientRecordSchema = z.object({
|
||||
adTag: z.string(),
|
||||
allowedIPs: z.string(),
|
||||
auth: z.string(),
|
||||
clientHostRuleIds: z.array(z.number().int()).optional(),
|
||||
comment: z.string(),
|
||||
createdAt: z.number().int(),
|
||||
email: z.string(),
|
||||
|
||||
@@ -1839,6 +1839,14 @@ export const sections: readonly Section[] = [
|
||||
responseSchema: 'HostGroup',
|
||||
responseSchemaArray: true,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/hosts/raw',
|
||||
summary:
|
||||
'List every host row with its numeric id, for id-based pickers such as the per-client host rule override.',
|
||||
responseSchema: 'Host',
|
||||
responseSchemaArray: true,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/hosts/get/:groupId',
|
||||
|
||||
@@ -37,6 +37,7 @@ import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
|
||||
import { resolveExternalLinkExpiry } from '@/lib/clients/external-link';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import { useClientHwids } from '@/hooks/useClientHwids';
|
||||
import { useRawHostsQuery } from '@/api/queries/useRawHostsQuery';
|
||||
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import ClientHwidListModal from '@/components/clients/ClientHwidList';
|
||||
@@ -136,6 +137,7 @@ type Values = ClientFormValues & {
|
||||
expiryDate: number;
|
||||
limitHwid: number;
|
||||
externalLinks: ExternalLinkRow[];
|
||||
clientHostRuleIds: number[];
|
||||
wgPrivateKey: string;
|
||||
wgPublicKey: string;
|
||||
wgPreSharedKey: string;
|
||||
@@ -155,6 +157,7 @@ const EMPTY: Values = {
|
||||
auth: '',
|
||||
flow: '',
|
||||
security: 'auto',
|
||||
clientHostRuleIds: [],
|
||||
reverseTag: '',
|
||||
totalGB: 0,
|
||||
expiryDate: 0,
|
||||
@@ -373,6 +376,11 @@ export default function ClientFormModal({
|
||||
? 'auto'
|
||||
: client.security,
|
||||
reverseTag: client.reverse?.tag || '',
|
||||
clientHostRuleIds: [
|
||||
...new Set(
|
||||
(client.clientHostRuleIds || []).filter((id) => Number.isInteger(id) && id > 0),
|
||||
),
|
||||
],
|
||||
totalGB: bytesToGB(client.totalGB || 0),
|
||||
reset: Number(client.reset) || 0,
|
||||
resetDay: Number(client.resetDay) || 0,
|
||||
@@ -573,6 +581,66 @@ export default function ClientFormModal({
|
||||
[inbounds, inboundIds],
|
||||
);
|
||||
|
||||
// Host options cover selected inbounds only; bound disabled hosts stay
|
||||
// visible with a suffix so open+save never silently drops them.
|
||||
const { hosts: rawHostRules } = useRawHostsQuery();
|
||||
const clientHostRuleIds = useWatch({ control: methods.control, name: 'clientHostRuleIds' });
|
||||
const hostRuleOptions = useMemo(() => {
|
||||
const selected = new Set(inboundIds || []);
|
||||
const bound = new Set(clientHostRuleIds || []);
|
||||
const formatBase = (h: { id?: number; remark?: string; address?: string }) =>
|
||||
h.remark || h.address
|
||||
? `${h.remark || h.address}${h.address && h.remark ? ` (${h.address})` : ''} (#${h.id})`
|
||||
: `Host #${h.id}`;
|
||||
const options: { value: number; label: string }[] = [];
|
||||
const seen = new Set<number>();
|
||||
for (const h of rawHostRules || []) {
|
||||
if (h.id === undefined || h.inboundId === undefined || !selected.has(h.inboundId)) continue;
|
||||
const id = h.id as number;
|
||||
const base = formatBase(h);
|
||||
if (!h.isDisabled) {
|
||||
options.push({ value: id, label: base });
|
||||
seen.add(id);
|
||||
} else if (bound.has(id)) {
|
||||
options.push({ value: id, label: t('pages.clients.hostRuleDisabled', { label: base }) });
|
||||
seen.add(id);
|
||||
}
|
||||
}
|
||||
for (const id of bound) {
|
||||
if (seen.has(id)) continue;
|
||||
const h = (rawHostRules || []).find((r) => r.id === id);
|
||||
if (h) {
|
||||
const base = formatBase(h);
|
||||
options.push({
|
||||
value: id,
|
||||
label: h.isDisabled ? t('pages.clients.hostRuleDisabled', { label: base }) : base,
|
||||
});
|
||||
} else {
|
||||
options.push({ value: id, label: `Host #${id}` });
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}, [rawHostRules, inboundIds, clientHostRuleIds, t]);
|
||||
|
||||
// Prune only bindings whose inbound was just deselected, never on
|
||||
// disabled state, so a temporarily disabled host survives untouched.
|
||||
function handleInboundChange(next: number[]) {
|
||||
const prevSelected = new Set(inboundIds || []);
|
||||
const nextSelected = new Set(next || []);
|
||||
const removed = new Set([...prevSelected].filter((id) => !nextSelected.has(id)));
|
||||
methods.setValue('inboundIds', next);
|
||||
if (removed.size === 0) return;
|
||||
const current = methods.getValues('clientHostRuleIds') || [];
|
||||
if (current.length === 0) return;
|
||||
const byId = new Map((rawHostRules || []).map((h) => [h.id as number, h]));
|
||||
const kept = current.filter((id) => {
|
||||
const host = byId.get(id);
|
||||
if (!host || host.inboundId === undefined) return true;
|
||||
return !removed.has(host.inboundId);
|
||||
});
|
||||
if (kept.length !== current.length) methods.setValue('clientHostRuleIds', kept);
|
||||
}
|
||||
|
||||
const expiryDayjs = useMemo<Dayjs | null>(
|
||||
() => (expiryDate > 0 ? dayjs(expiryDate) : null),
|
||||
[expiryDate],
|
||||
@@ -674,6 +742,7 @@ export default function ClientFormModal({
|
||||
auth: values.auth,
|
||||
flow: values.flow,
|
||||
security: values.security,
|
||||
clientHostRuleIds: values.clientHostRuleIds,
|
||||
reverseTag: values.reverseTag,
|
||||
totalGB: values.totalGB,
|
||||
delayedStart: values.delayedStart,
|
||||
@@ -710,6 +779,10 @@ export default function ClientFormModal({
|
||||
auth: values.auth,
|
||||
flow: showFlow ? values.flow || '' : '',
|
||||
security: showSecurity ? values.security || 'auto' : 'auto',
|
||||
// An empty list clears the override.
|
||||
clientHostRuleIds: Array.isArray(values.clientHostRuleIds)
|
||||
? values.clientHostRuleIds.filter((id) => typeof id === 'number' && id > 0)
|
||||
: [],
|
||||
totalGB: totalBytes,
|
||||
expiryTime,
|
||||
reset: Number(values.reset) || 0,
|
||||
@@ -1086,31 +1159,57 @@ export default function ClientFormModal({
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{(tgBotEnable || showReverseTag) && (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
label={t('pages.clients.hostRuleOverride')}
|
||||
htmlFor={`${uid}-host-rule-override`}
|
||||
>
|
||||
<Select
|
||||
id={`${uid}-host-rule-override`}
|
||||
mode="multiple"
|
||||
value={clientHostRuleIds || []}
|
||||
onChange={(v) => methods.setValue('clientHostRuleIds', v)}
|
||||
options={hostRuleOptions}
|
||||
placeholder={t('pages.clients.hostRuleOverridePlaceholder')}
|
||||
maxTagCount="responsive"
|
||||
placement="topLeft"
|
||||
listHeight={220}
|
||||
allowClear
|
||||
showSearch={{
|
||||
filterOption: (input, option) =>
|
||||
((option?.label as string) || '')
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase()),
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{showReverseTag && (
|
||||
<Col xs={24} md={12}>
|
||||
<FormField name="reverseTag" label={t('pages.clients.reverseTag')}>
|
||||
<Input placeholder={t('pages.clients.reverseTagPlaceholder')} />
|
||||
</FormField>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{tgBotEnable && (
|
||||
<Row gutter={16}>
|
||||
{tgBotEnable && (
|
||||
<Col xs={24} md={12}>
|
||||
<FormField
|
||||
name="tgId"
|
||||
label={t('pages.clients.telegramId')}
|
||||
transform={{ output: (v) => Number(v) || 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
controls={false}
|
||||
placeholder={t('pages.clients.telegramIdPlaceholder')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormField>
|
||||
</Col>
|
||||
)}
|
||||
{showReverseTag && (
|
||||
<Col xs={24} md={12}>
|
||||
<FormField name="reverseTag" label={t('pages.clients.reverseTag')}>
|
||||
<Input placeholder={t('pages.clients.reverseTagPlaceholder')} />
|
||||
</FormField>
|
||||
</Col>
|
||||
)}
|
||||
<Col xs={24} md={12}>
|
||||
<FormField
|
||||
name="tgId"
|
||||
label={t('pages.clients.telegramId')}
|
||||
transform={{ output: (v) => Number(v) || 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
controls={false}
|
||||
placeholder={t('pages.clients.telegramIdPlaceholder')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
@@ -1122,13 +1221,13 @@ export default function ClientFormModal({
|
||||
<SelectAllClearButtons
|
||||
options={inboundOptions}
|
||||
value={inboundIds}
|
||||
onChange={(v) => methods.setValue('inboundIds', v)}
|
||||
onChange={handleInboundChange}
|
||||
/>
|
||||
<Select
|
||||
id={`${uid}-inbounds`}
|
||||
mode="multiple"
|
||||
value={inboundIds}
|
||||
onChange={(v) => methods.setValue('inboundIds', v)}
|
||||
onChange={handleInboundChange}
|
||||
options={inboundOptions}
|
||||
placeholder={t('pages.clients.selectInbound')}
|
||||
maxTagCount="responsive"
|
||||
|
||||
@@ -32,6 +32,8 @@ export const ClientRecordSchema = z
|
||||
auth: z.string().optional(),
|
||||
flow: z.string().optional(),
|
||||
security: z.string().optional(),
|
||||
// Host rules the client is bound to; null/omitted preserves, empty clears.
|
||||
clientHostRuleIds: z.array(z.number().int()).nullable().optional(),
|
||||
totalGB: z.number().optional(),
|
||||
expiryTime: z.number().optional(),
|
||||
limitIp: z.number().optional(),
|
||||
@@ -324,6 +326,8 @@ export const ClientFormSchema = z.object({
|
||||
auth: z.string(),
|
||||
flow: z.string(),
|
||||
security: z.string(),
|
||||
// Host rules the client is bound to; null/omitted preserves, empty clears.
|
||||
clientHostRuleIds: z.array(z.number().int()).nullable().optional(),
|
||||
reverseTag: z.string(),
|
||||
totalGB: z.number().min(0),
|
||||
delayedStart: z.boolean(),
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import ClientFormModal from '@/pages/clients/ClientFormModal';
|
||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||
|
||||
vi.mock('@/api/queries/useRawHostsQuery', () => ({
|
||||
useRawHostsQuery: () => ({
|
||||
hosts: [
|
||||
{ id: 99, inboundId: 1, remark: 'cdn-disabled', address: 'c.example.com', isDisabled: true },
|
||||
{ id: 100, inboundId: 1, remark: 'cdn-live', address: 'live.example.com', isDisabled: false },
|
||||
{ id: 200, inboundId: 2, remark: 'other', address: 'o.example.com', isDisabled: false },
|
||||
],
|
||||
loading: false,
|
||||
fetched: true,
|
||||
fetchError: '',
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeQC() {
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
}
|
||||
|
||||
const INBOUNDS = [
|
||||
{ id: 1, port: 443, protocol: 'vless', tag: 'in-1', remark: 'one', enable: true },
|
||||
{ id: 2, port: 8443, protocol: 'vless', tag: 'in-2', remark: 'two', enable: true },
|
||||
] as unknown as InboundOption[];
|
||||
|
||||
const CLIENT = {
|
||||
email: 'bound@example.com',
|
||||
uuid: '11111111-1111-1111-1111-111111111111',
|
||||
subId: 'subid123',
|
||||
enable: true,
|
||||
clientHostRuleIds: [99],
|
||||
} as unknown as ClientRecord;
|
||||
|
||||
function renderEdit(save: ComponentProps<typeof ClientFormModal>['save']) {
|
||||
const qc = makeQC();
|
||||
return {
|
||||
save,
|
||||
view: (
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={qc}>
|
||||
<ClientFormModal
|
||||
open
|
||||
mode="edit"
|
||||
client={CLIENT}
|
||||
inbounds={INBOUNDS}
|
||||
attachedIds={[1]}
|
||||
save={save}
|
||||
onOpenChange={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ClientFormModal — disabled host binding preservation', () => {
|
||||
it('keeps a disabled host binding when saving an unrelated field', async () => {
|
||||
const save = vi.fn().mockResolvedValue({ success: true });
|
||||
const { view } = renderEdit(save);
|
||||
render(view);
|
||||
|
||||
const comment = await screen.findByLabelText('Comment');
|
||||
fireEvent.change(comment, { target: { value: 'touched' } });
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /save/i }));
|
||||
await waitFor(() => expect(save).toHaveBeenCalled());
|
||||
const payload = save.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(payload.clientHostRuleIds).toContain(99);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { act, cleanup, fireEvent, screen, within } from '@testing-library/react';
|
||||
import { EditorView } from 'codemirror';
|
||||
|
||||
@@ -39,6 +39,13 @@ function routingLink(value: Record<string, unknown>, mode = 'onadd') {
|
||||
return `happ://routing/${mode}/${toBase64Utf8(JSON.stringify(value))}`;
|
||||
}
|
||||
|
||||
// Unmount while jsdom is alive so CodeMirror destroys its view
|
||||
// before the shared cleanup drains pending scheduler callbacks.
|
||||
const mounted: Array<() => void> = [];
|
||||
afterEach(() => {
|
||||
while (mounted.length) mounted.pop()?.();
|
||||
});
|
||||
|
||||
function renderSettings(input = routingLink(profile)) {
|
||||
const updateSetting = vi.fn<(patch: Partial<AllSetting>) => void>();
|
||||
|
||||
@@ -60,7 +67,8 @@ function renderSettings(input = routingLink(profile)) {
|
||||
);
|
||||
}
|
||||
|
||||
renderWithProviders(<SettingsHarness />);
|
||||
const { unmount } = renderWithProviders(<SettingsHarness />);
|
||||
mounted.push(unmount);
|
||||
return updateSetting;
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,8 @@ import { HttpUtil, Msg } from '@/utils';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.spyOn(HttpUtil, 'post').mockResolvedValue({ success: true, obj: {} } as any);
|
||||
vi.spyOn(HttpUtil, 'get').mockImplementation(
|
||||
async (url: string) => new Msg(true, '', url.includes('/panel/api/inbounds/options') ? [] : {}),
|
||||
);
|
||||
// Array-returning endpoints must resolve to [] so Zod list schemas validate.
|
||||
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
|
||||
const isList = url.includes('/panel/api/inbounds/options') || url.includes('/panel/api/hosts/');
|
||||
return new Msg(true, '', isList ? [] : {});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user