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:
Nethmina Yasas
2026-10-24 16:03:02 +05:30
committed by GitHub
parent d103c0c012
commit bc1b750e00
47 changed files with 1940 additions and 168 deletions
@@ -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);
});
});
+10 -2
View File
@@ -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;
}
+5 -3
View File
@@ -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 ? [] : {});
});