mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-03 03:35:04 +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:
@@ -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