mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-17 07:37:15 +00:00
feat(nord): support multi-server NordLynx outbounds (#6311)
* feat(nord): support multi-server NordLynx outbounds * fix(nord): address verified PR review findings Tighten the NordVPN multi-outbound implementation and its regression coverage based on the verified review feedback. - remove the redundant Xray validation test that duplicated the base branch and did not exercise multiple outbounds - make NordModal tests wait for server loading and assert the modal close callback, duplicate-server state, and endpoint behavior - add coverage for resolving the NordLynx public key from technology metadata instead of a numeric technology ID - use a real httptest server for Nord integration tests through an injectable API base URL - represent the All Cities sentinel consistently as null and reset it when a country changes The existing NordVPN API contracts and persisted outbound schema remain unchanged.
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import NordModal from '@/pages/xray/overrides/NordModal';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const NORD_DATA = { token: 'nord-token', private_key: 'current-private-key' };
|
||||
const COUNTRIES = [{ id: 228, name: 'United States', code: 'US' }];
|
||||
const SERVER_DATA = {
|
||||
locations: [
|
||||
{ id: 10, country: { city: { id: 100, name: 'New York' } } },
|
||||
{ id: 20, country: { city: { id: 200, name: 'Los Angeles' } } },
|
||||
],
|
||||
servers: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'United States #1',
|
||||
hostname: 'us1.nordvpn.com',
|
||||
station: '198.51.100.10',
|
||||
load: 12,
|
||||
location_ids: [10],
|
||||
technologies: [{ id: 35, metadata: [{ name: 'public_key', value: 'public-one' }] }],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'United States #2',
|
||||
hostname: 'us2.nordvpn.com',
|
||||
station: '198.51.100.20',
|
||||
load: 24,
|
||||
location_ids: [20],
|
||||
technologies: [{ id: 35, metadata: [{ name: 'public_key', value: 'public-two' }] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function nordApiPost(url: string) {
|
||||
if (url === '/panel/api/xray/nord/data') {
|
||||
return new Msg(true, '', JSON.stringify(NORD_DATA));
|
||||
}
|
||||
if (url === '/panel/api/xray/nord/countries') {
|
||||
return new Msg(true, '', JSON.stringify(COUNTRIES));
|
||||
}
|
||||
if (url === '/panel/api/xray/nord/servers') {
|
||||
return new Msg(true, '', JSON.stringify(SERVER_DATA));
|
||||
}
|
||||
if (url === '/panel/api/xray/nord/del') return new Msg(true, '', '');
|
||||
return new Msg(false, `Unexpected POST ${url}`, null);
|
||||
}
|
||||
|
||||
function mockNordApi() {
|
||||
vi.mocked(HttpUtil.post).mockImplementation(async (url: string) => nordApiPost(url));
|
||||
}
|
||||
|
||||
function visibleOptions(): HTMLElement[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function chooseOption(testId: string, labelPart: string) {
|
||||
const node = screen.getByTestId(testId);
|
||||
const select = node.closest('.ant-select') ?? node;
|
||||
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
|
||||
await waitFor(() => expect(visibleOptions().length).toBeGreaterThan(0));
|
||||
const option = visibleOptions().find((item) =>
|
||||
`${item.getAttribute('title') ?? ''} ${item.textContent ?? ''}`.includes(labelPart),
|
||||
);
|
||||
if (!option) throw new Error(`Missing option containing ${labelPart}`);
|
||||
fireEvent.click(option);
|
||||
}
|
||||
|
||||
async function clickAddOutbound() {
|
||||
const button = await waitFor(() => {
|
||||
const candidate = screen.getByRole('button', { name: /Add outbound/ });
|
||||
if ((candidate as HTMLButtonElement).disabled) throw new Error('Add outbound still disabled');
|
||||
return candidate;
|
||||
});
|
||||
fireEvent.click(button);
|
||||
}
|
||||
|
||||
function NordHarness({
|
||||
initial = [],
|
||||
onAdded,
|
||||
onClose = vi.fn(),
|
||||
}: {
|
||||
initial?: Record<string, unknown>[];
|
||||
onAdded?: (outbound: Record<string, unknown>) => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [outbounds, setOutbounds] = useState(initial);
|
||||
return (
|
||||
<>
|
||||
<output data-testid="outbound-state">{JSON.stringify(outbounds)}</output>
|
||||
<NordModal
|
||||
open
|
||||
templateSettings={{ outbounds }}
|
||||
onClose={onClose}
|
||||
onAddOutbound={(outbound) => {
|
||||
onAdded?.(outbound);
|
||||
setOutbounds((previous) => [...previous, outbound]);
|
||||
}}
|
||||
onResetOutbound={({ index, outbound }) => {
|
||||
setOutbounds((previous) =>
|
||||
previous.map((existing, current) => (current === index ? outbound : existing)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('NordVPN modal', () => {
|
||||
it('shows access-token and private-key entry while signed out', async () => {
|
||||
vi.mocked(HttpUtil.post).mockImplementation(async (url: string) => {
|
||||
if (url === '/panel/api/xray/nord/data') return new Msg(true, '', '');
|
||||
return new Msg(false, `Unexpected POST ${url}`, null);
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<NordModal
|
||||
open
|
||||
templateSettings={{ outbounds: [] }}
|
||||
onClose={vi.fn()}
|
||||
onAddOutbound={vi.fn()}
|
||||
onResetOutbound={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('Access token')).toBeTruthy());
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Private key' }));
|
||||
expect(await screen.findByPlaceholderText('Private key')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('adds multiple different NordLynx outbounds without closing the modal', async () => {
|
||||
mockNordApi();
|
||||
const added: Record<string, unknown>[] = [];
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(
|
||||
<NordHarness onAdded={(outbound) => added.push(outbound)} onClose={onClose} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('nord-token')).toBeTruthy());
|
||||
await chooseOption('nord-country-select', 'United States');
|
||||
await waitFor(() => expect(screen.getByTestId('nord-server-select')).toBeTruthy());
|
||||
await clickAddOutbound();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('nord-added-table')).toBeTruthy());
|
||||
expect(screen.getByText('nord-us1.nordvpn.com')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByTestId('nord-added-table').querySelector('.nord-added-server-endpoint')
|
||||
?.textContent,
|
||||
).toBe('198.51.100.10:51820');
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
await chooseOption('nord-server-select', 'United States #2');
|
||||
await clickAddOutbound();
|
||||
await waitFor(() => expect(screen.getByText('nord-us2.nordvpn.com')).toBeTruthy());
|
||||
|
||||
expect(added).toHaveLength(2);
|
||||
expect(added[0]).toMatchObject({
|
||||
tag: 'nord-us1.nordvpn.com',
|
||||
protocol: 'wireguard',
|
||||
settings: {
|
||||
secretKey: 'current-private-key',
|
||||
address: ['10.5.0.2/32'],
|
||||
peers: [{ publicKey: 'public-one', endpoint: '198.51.100.10:51820' }],
|
||||
noKernelTun: true,
|
||||
},
|
||||
});
|
||||
expect(added[1]).toMatchObject({
|
||||
tag: 'nord-us2.nordvpn.com',
|
||||
settings: {
|
||||
peers: [{ publicKey: 'public-two', endpoint: '198.51.100.20:51820' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('shows concise server details and load in the server picker', async () => {
|
||||
mockNordApi();
|
||||
renderWithProviders(<NordHarness />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('nord-token')).toBeTruthy());
|
||||
await chooseOption('nord-country-select', 'United States');
|
||||
await waitFor(() => expect(screen.getByTestId('nord-server-select')).toBeTruthy());
|
||||
|
||||
const node = screen.getByTestId('nord-server-select');
|
||||
const select = node.closest('.ant-select') ?? node;
|
||||
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
document.querySelectorAll<HTMLElement>('.nord-server-popup .ant-select-item-option'),
|
||||
).toHaveLength(2),
|
||||
);
|
||||
const options = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('.nord-server-popup .ant-select-item-option'),
|
||||
);
|
||||
expect(options[0].querySelector('.nord-server-option-name')?.textContent).toBe(
|
||||
'United States #1',
|
||||
);
|
||||
expect(options[0].querySelector('.nord-server-option-hostname')?.textContent).toBe(
|
||||
'us1.nordvpn.com',
|
||||
);
|
||||
expect(options[0].querySelector('.nord-server-option-address')?.textContent).toBe(
|
||||
'198.51.100.10:51820',
|
||||
);
|
||||
expect(options[0].querySelector('.nord-server-load-value')?.textContent).toBe('12%');
|
||||
expect(options[1].querySelector('.nord-server-load-value')?.textContent).toBe('24%');
|
||||
});
|
||||
|
||||
it('shows the country flag and selects All Cities after loading servers', async () => {
|
||||
mockNordApi();
|
||||
renderWithProviders(<NordHarness />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('nord-token')).toBeTruthy());
|
||||
await chooseOption('nord-country-select', 'United States');
|
||||
|
||||
const countrySelect = screen.getByTestId('nord-country-select').closest('.ant-select');
|
||||
expect(countrySelect?.textContent).toContain('🇺🇸 United States (US)');
|
||||
|
||||
await waitFor(() => {
|
||||
const select = screen.getByTestId('nord-city-select').closest('.ant-select');
|
||||
if (!select?.textContent?.includes('All Cities')) {
|
||||
throw new Error('All Cities is not selected');
|
||||
}
|
||||
});
|
||||
|
||||
const serverNode = screen.getByTestId('nord-server-select');
|
||||
const serverSelect = serverNode.closest('.ant-select') ?? serverNode;
|
||||
fireEvent.mouseDown(serverSelect.querySelector('.ant-select-selector') ?? serverSelect);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
document.querySelectorAll<HTMLElement>('.nord-server-popup .ant-select-item-option'),
|
||||
).toHaveLength(2),
|
||||
);
|
||||
});
|
||||
|
||||
it('disables Add when the selected server is already present', async () => {
|
||||
mockNordApi();
|
||||
renderWithProviders(
|
||||
<NordHarness initial={[{ tag: 'nord-us1.nordvpn.com', protocol: 'wireguard' }]} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('nord-token')).toBeTruthy());
|
||||
await chooseOption('nord-country-select', 'United States');
|
||||
await waitFor(() => expect(screen.getByTestId('nord-server-select')).toBeTruthy());
|
||||
await waitFor(() => {
|
||||
const button = screen.getByRole('button', { name: /Add outbound/ });
|
||||
expect((button as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.getByText(/already in the outbound list/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes only the selected existing outbound private key', async () => {
|
||||
mockNordApi();
|
||||
const onResetOutbound = vi.fn();
|
||||
const nordOutbound = {
|
||||
tag: 'nord-us9.nordvpn.com',
|
||||
protocol: 'wireguard',
|
||||
sendThrough: '192.0.2.8',
|
||||
settings: {
|
||||
secretKey: 'old-private-key',
|
||||
address: ['10.5.0.2/32'],
|
||||
noKernelTun: true,
|
||||
customOption: 'preserve-me',
|
||||
peers: [{ publicKey: 'old-public', endpoint: '198.51.100.90:51820' }],
|
||||
},
|
||||
};
|
||||
renderWithProviders(
|
||||
<NordModal
|
||||
open
|
||||
templateSettings={{
|
||||
outbounds: [{ tag: 'direct', protocol: 'freedom' }, nordOutbound],
|
||||
}}
|
||||
onClose={vi.fn()}
|
||||
onAddOutbound={vi.fn()}
|
||||
onResetOutbound={onResetOutbound}
|
||||
/>,
|
||||
);
|
||||
|
||||
const reset = await waitFor(() => screen.getByTestId('nord-reset-1'));
|
||||
fireEvent.click(reset);
|
||||
await waitFor(() => expect(onResetOutbound).toHaveBeenCalledTimes(1));
|
||||
expect(onResetOutbound.mock.calls[0][0]).toEqual({
|
||||
index: 1,
|
||||
outbound: {
|
||||
...nordOutbound,
|
||||
settings: { ...nordOutbound.settings, secretKey: 'current-private-key' },
|
||||
},
|
||||
oldTag: 'nord-us9.nordvpn.com',
|
||||
newTag: 'nord-us9.nordvpn.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows malformed Nord rows but disables their Reset action', async () => {
|
||||
mockNordApi();
|
||||
renderWithProviders(
|
||||
<NordModal
|
||||
open
|
||||
templateSettings={{
|
||||
outbounds: [{ tag: 'nord-broken', protocol: 'wireguard', settings: {} }],
|
||||
}}
|
||||
onClose={vi.fn()}
|
||||
onAddOutbound={vi.fn()}
|
||||
onResetOutbound={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const reset = await waitFor(() => screen.getByTestId('nord-reset-0'));
|
||||
expect((reset as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.getByText('nord-broken')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('clears credentials on logout without removing configured outbounds', async () => {
|
||||
mockNordApi();
|
||||
renderWithProviders(
|
||||
<NordHarness
|
||||
initial={[
|
||||
{
|
||||
tag: 'nord-us1.nordvpn.com',
|
||||
protocol: 'wireguard',
|
||||
settings: { secretKey: 'embedded-private-key' },
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('nord-token')).toBeTruthy());
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Log Out' }));
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('Access token')).toBeTruthy());
|
||||
expect(screen.getByTestId('outbound-state').textContent).toContain('nord-us1.nordvpn.com');
|
||||
expect(vi.mocked(HttpUtil.post)).toHaveBeenCalledWith('/panel/api/xray/nord/del');
|
||||
});
|
||||
|
||||
it('does not add a server that omits its NordLynx public key', async () => {
|
||||
const onAddOutbound = vi.fn();
|
||||
vi.mocked(HttpUtil.post).mockImplementation(async (url: string) => {
|
||||
if (url === '/panel/api/xray/nord/data') {
|
||||
return new Msg(true, '', JSON.stringify(NORD_DATA));
|
||||
}
|
||||
if (url === '/panel/api/xray/nord/countries') {
|
||||
return new Msg(true, '', JSON.stringify(COUNTRIES));
|
||||
}
|
||||
if (url === '/panel/api/xray/nord/servers') {
|
||||
return new Msg(
|
||||
true,
|
||||
'',
|
||||
JSON.stringify({
|
||||
...SERVER_DATA,
|
||||
servers: [{ ...SERVER_DATA.servers[0], technologies: [{ id: 35, metadata: [] }] }],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return new Msg(false, `Unexpected POST ${url}`, null);
|
||||
});
|
||||
renderWithProviders(
|
||||
<NordModal
|
||||
open
|
||||
templateSettings={{ outbounds: [] }}
|
||||
onClose={vi.fn()}
|
||||
onAddOutbound={onAddOutbound}
|
||||
onResetOutbound={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('nord-token')).toBeTruthy());
|
||||
await chooseOption('nord-country-select', 'United States');
|
||||
await clickAddOutbound();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText('Selected server does not advertise a NordLynx public key.'),
|
||||
).toBeTruthy(),
|
||||
);
|
||||
expect(onAddOutbound).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads the NordLynx public key without coupling to a numeric technology ID', async () => {
|
||||
const onAddOutbound = vi.fn();
|
||||
vi.mocked(HttpUtil.post).mockImplementation(async (url: string) => {
|
||||
if (url === '/panel/api/xray/nord/data') {
|
||||
return new Msg(true, '', JSON.stringify(NORD_DATA));
|
||||
}
|
||||
if (url === '/panel/api/xray/nord/countries') {
|
||||
return new Msg(true, '', JSON.stringify(COUNTRIES));
|
||||
}
|
||||
if (url === '/panel/api/xray/nord/servers') {
|
||||
return new Msg(
|
||||
true,
|
||||
'',
|
||||
JSON.stringify({
|
||||
...SERVER_DATA,
|
||||
servers: [
|
||||
{
|
||||
...SERVER_DATA.servers[0],
|
||||
technologies: [
|
||||
{ id: 999, metadata: [{ name: 'public_key', value: 'future-public-key' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return new Msg(false, `Unexpected POST ${url}`, null);
|
||||
});
|
||||
renderWithProviders(
|
||||
<NordModal
|
||||
open
|
||||
templateSettings={{ outbounds: [] }}
|
||||
onClose={vi.fn()}
|
||||
onAddOutbound={onAddOutbound}
|
||||
onResetOutbound={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('nord-token')).toBeTruthy());
|
||||
await chooseOption('nord-country-select', 'United States');
|
||||
await clickAddOutbound();
|
||||
await waitFor(() => expect(onAddOutbound).toHaveBeenCalledTimes(1));
|
||||
expect(onAddOutbound.mock.calls[0][0]).toMatchObject({
|
||||
settings: { peers: [{ publicKey: 'future-public-key' }] },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user