mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-17 15:47:14 +00:00
f9cfd87cb2
* 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.
428 lines
15 KiB
TypeScript
428 lines
15 KiB
TypeScript
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' }] },
|
|
});
|
|
});
|
|
});
|