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:
Masterain
2026-09-03 02:20:10 +08:00
committed by GitHub
parent f727d04f65
commit f9cfd87cb2
23 changed files with 1186 additions and 215 deletions
+427
View File
@@ -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' }] },
});
});
});