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
-15
View File
@@ -144,19 +144,6 @@ export default function XrayPage() {
if (idx >= 0) tt.outbounds.splice(idx, 1);
});
}
function onRemoveOutboundByIndex(index: number) {
mutate((tt) => {
if (tt.outbounds && index >= 0) tt.outbounds.splice(index, 1);
});
}
function onRemoveRoutingRules(payload: { prefix: string }) {
mutate((tt) => {
const rules = tt.routing?.rules;
if (!Array.isArray(rules)) return;
tt.routing!.rules = rules.filter((r) => !r?.outboundTag?.startsWith?.(payload.prefix));
});
}
const advancedText = useMemo(() => {
if (advSettings === 'xraySetting') return xraySetting;
const tpl = templateSettings;
@@ -393,8 +380,6 @@ export default function XrayPage() {
onClose={() => setNordOpen(false)}
onAddOutbound={onAddOutbound}
onResetOutbound={onResetOutbound}
onRemoveOutbound={onRemoveOutboundByIndex}
onRemoveRoutingRules={onRemoveRoutingRules}
/>
<PiaModal
open={piaOpen}
+302 -16
View File
@@ -1,41 +1,327 @@
.nord-modal .ant-modal-container {
overflow: hidden;
}
.nord-modal .ant-modal-body {
max-height: min(720px, calc(100vh - 160px));
overflow-y: auto;
padding-right: 2px;
}
.nord-login-form {
margin-top: 20px;
}
.nord-login-action {
display: block;
margin-left: auto;
}
.nord-account-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border: 1px solid var(--ant-color-border-secondary);
border-radius: var(--ant-border-radius-lg);
background: var(--ant-color-fill-quaternary);
}
.nord-data-table {
margin: 5px 0;
width: 100%;
flex: 1;
min-width: 0;
border-collapse: collapse;
}
.nord-data-table tr + tr td {
padding-top: 8px;
}
.nord-data-table td {
padding: 4px 8px;
padding: 0;
vertical-align: top;
}
.nord-data-table td:first-child {
width: 112px;
padding-right: 12px;
color: var(--ant-color-text-secondary);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.nord-data-table td:last-child {
word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.nord-data-table td:first-child {
font-family: inherit;
font-weight: 500;
white-space: nowrap;
width: 130px;
.nord-section-divider {
margin: 18px 0 14px;
color: var(--ant-color-text-secondary);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
}
.nord-data-table .row-odd {
background: var(--ant-color-fill-tertiary);
.nord-location-form .ant-form-item {
margin-bottom: 0;
}
.server-row {
display: inline-flex;
.nord-location-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 12px;
}
.nord-server-field {
grid-column: 1 / -1;
min-width: 0;
}
.nord-server-popup .ant-select-item-option {
min-height: 44px;
padding: 8px 10px;
}
.nord-server-popup .ant-select-item-option-content {
overflow: visible;
}
.nord-server-option {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-width: 0;
}
.nord-server-option-copy {
display: flex;
flex: 1;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
}
.server-name {
.nord-server-option-name {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
color: var(--ant-color-text);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.nord-server-option-meta {
display: flex;
flex: 1;
align-items: center;
gap: 6px;
min-width: 0;
overflow: hidden;
color: var(--ant-color-text-secondary);
font-size: 12px;
white-space: nowrap;
}
.nord-server-option-hostname {
overflow: hidden;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
text-overflow: ellipsis;
}
.nord-server-option-address,
.nord-selected-server-address,
.nord-added-server-endpoint {
color: var(--ant-color-text-tertiary);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
white-space: nowrap;
}
.nord-server-option-address {
overflow: hidden;
text-overflow: ellipsis;
}
.server-load-tag {
margin-right: 0;
.nord-server-load {
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: 5px;
margin-left: auto;
padding: 2px 7px;
border: 1px solid currentcolor;
border-radius: 999px;
background: color-mix(in srgb, currentcolor 8%, transparent);
font-size: 12px;
font-weight: 600;
line-height: 20px;
}
.nord-server-load-low {
color: var(--ant-color-success);
}
.nord-server-load-medium {
color: var(--ant-color-warning);
}
.nord-server-load-high {
color: var(--ant-color-error);
}
.nord-server-load-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentcolor;
}
.nord-server-load-label {
color: var(--ant-color-text-secondary);
font-weight: 500;
}
.nord-server-load-value {
font-variant-numeric: tabular-nums;
}
.nord-selected-server {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
}
.nord-selected-server-name {
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.nord-selected-server-hostname {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
color: var(--ant-color-text-secondary);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.nord-selected-server-address {
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
}
.nord-add-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 14px;
}
.nord-already-added {
flex: 1;
color: var(--ant-color-text-secondary);
font-size: 12px;
}
.nord-added-table {
width: 100%;
margin: 0;
border: 1px solid var(--ant-color-border-secondary);
border-radius: var(--ant-border-radius-lg);
border-collapse: separate;
border-spacing: 0;
background: var(--ant-color-fill-quaternary);
overflow: hidden;
}
.nord-added-table tr + tr td {
border-top: 1px solid var(--ant-color-border-secondary);
}
.nord-added-table td {
padding: 9px 12px;
vertical-align: middle;
}
.nord-added-table td:first-child {
padding-right: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nord-added-server-tag {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.nord-added-server-endpoint {
margin-left: 12px;
}
.nord-added-table td:last-child {
width: 1%;
white-space: nowrap;
text-align: right;
}
@media (max-width: 575px) {
.nord-modal {
max-width: calc(100vw - 24px);
margin: 12px auto;
}
.nord-modal .ant-modal-body {
max-height: calc(100vh - 124px);
}
.nord-account-card {
flex-direction: column;
}
.nord-account-card > .ant-btn {
align-self: flex-end;
}
.nord-location-grid {
grid-template-columns: minmax(0, 1fr);
}
.nord-server-field {
grid-column: auto;
}
.nord-selected-server-hostname,
.nord-selected-server-address,
.nord-server-load-label {
display: none;
}
.nord-server-option-meta > span:first-child,
.nord-server-option-meta > span[aria-hidden='true'],
.nord-server-option-address {
display: none;
}
.nord-add-actions {
align-items: stretch;
flex-direction: column;
}
.nord-add-actions .ant-btn {
width: 100%;
}
}
+292 -142
View File
@@ -1,16 +1,17 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Form, Input, message, Modal, Select, Tabs, Tag } from 'antd';
import { Button, Divider, Form, Input, message, Modal, Select, Tabs } from 'antd';
import { LoginOutlined, SaveOutlined } from '@ant-design/icons';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { HttpUtil } from '@/utils';
import { FormField } from '@/components/form/rhf';
import { countryFlag, countryName } from '../outbounds/outbounds-tab-helpers';
import './NordModal.css';
interface NordModalProps {
open: boolean;
templateSettings: { outbounds?: { tag?: string }[] } | null;
templateSettings: { outbounds?: NordOutboundRow[] } | null;
onClose: () => void;
onAddOutbound: (outbound: Record<string, unknown>) => void;
onResetOutbound: (payload: {
@@ -19,8 +20,19 @@ interface NordModalProps {
oldTag?: string;
newTag: string;
}) => void;
onRemoveOutbound: (index: number) => void;
onRemoveRoutingRules: (payload: { prefix: string }) => void;
}
interface NordOutboundRow {
tag?: string;
protocol?: string;
settings?: unknown;
}
interface NordAddedRow {
index: number;
tag: string;
endpoint: string;
resettable: boolean;
}
interface NordData {
@@ -45,12 +57,19 @@ interface NordServer {
hostname: string;
station: string;
load: number;
technologies?: { id: number; metadata?: { name: string; value: string }[] }[];
technologies?: { metadata?: { name: string; value: string }[] }[];
location_ids?: number[];
cityId?: number | null;
cityName?: string;
}
interface NordServerOption {
value: number;
label: string;
searchText: string;
server: NordServer;
}
interface NordFormValues {
token: string;
manualKey: string;
@@ -67,10 +86,30 @@ const EMPTY: NordFormValues = {
serverId: null,
};
function loadColor(load: number): string {
if (load < 30) return 'green';
if (load < 70) return 'orange';
return 'red';
function loadLevel(load: number): 'low' | 'medium' | 'high' {
if (load < 30) return 'low';
if (load < 70) return 'medium';
return 'high';
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isResettableNordOutbound(outbound: NordOutboundRow): boolean {
if (outbound.protocol !== 'wireguard' || !isRecord(outbound.settings)) return false;
return (
Array.isArray(outbound.settings.address) &&
outbound.settings.address.length > 0 &&
Array.isArray(outbound.settings.peers) &&
outbound.settings.peers.length > 0
);
}
function nordOutboundEndpoint(outbound: NordOutboundRow): string {
if (!isRecord(outbound.settings) || !Array.isArray(outbound.settings.peers)) return '';
const peer = outbound.settings.peers.find(isRecord);
return typeof peer?.endpoint === 'string' ? peer.endpoint : '';
}
export default function NordModal({
@@ -79,10 +118,8 @@ export default function NordModal({
onClose,
onAddOutbound,
onResetOutbound,
onRemoveOutbound,
onRemoveRoutingRules,
}: NordModalProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const [nordData, setNordData] = useState<NordData | null>(null);
@@ -92,18 +129,47 @@ export default function NordModal({
const methods = useForm<NordFormValues>({ defaultValues: EMPTY });
const cityId = useWatch({ control: methods.control, name: 'cityId' });
const serverId = useWatch({ control: methods.control, name: 'serverId' });
const locale = i18n.resolvedLanguage || i18n.language;
const nordOutboundIndex = useMemo(() => {
const nordRows = useMemo<NordAddedRow[]>(() => {
const list = templateSettings?.outbounds;
if (!list) return -1;
return list.findIndex((o) => o?.tag?.startsWith?.('nord-'));
if (!list) return [];
return list.flatMap((outbound, index) => {
const tag = outbound?.tag;
if (typeof tag !== 'string' || !tag.startsWith('nord-')) return [];
return [
{
index,
tag,
endpoint: nordOutboundEndpoint(outbound),
resettable: isResettableNordOutbound(outbound),
},
];
});
}, [templateSettings?.outbounds]);
const addedTags = useMemo(() => new Set(nordRows.map((row) => row.tag)), [nordRows]);
const filteredServers = useMemo(() => {
if (!cityId) return servers;
if (cityId == null) return servers;
return servers.filter((s) => s.cityId === cityId);
}, [cityId, servers]);
const selectedServer = filteredServers.find((server) => server.id === serverId);
const selectedTag = selectedServer ? `nord-${selectedServer.hostname}` : '';
const selectedAlreadyAdded = Boolean(selectedTag && addedTags.has(selectedTag));
const serverOptions = useMemo<NordServerOption[]>(
() =>
filteredServers.map((server) => ({
value: server.id,
label: server.hostname,
searchText:
`${server.cityName ?? ''} ${server.name} ${server.hostname} ${server.station}`.toLowerCase(),
server,
})),
[filteredServers],
);
useEffect(() => {
methods.setValue('serverId', filteredServers.length > 0 ? filteredServers[0].id : null);
}, [filteredServers, methods]);
@@ -174,8 +240,6 @@ export default function NordModal({
try {
const msg = await HttpUtil.post('/panel/api/xray/nord/del');
if (msg?.success) {
onRemoveOutbound(nordOutboundIndex);
onRemoveRoutingRules({ prefix: 'nord-' });
setNordData(null);
methods.reset(EMPTY);
setCountries([]);
@@ -216,6 +280,7 @@ export default function NordModal({
return { ...s, cityId: city?.id || null, cityName: city?.name || 'Unknown' };
})
.sort((a: NordServer, b: NordServer) => a.load - b.load);
methods.setValue('cityId', null);
setServers(next);
if (next.length === 0) messageApi.warning(t('pages.xray.nord.noServers'));
} finally {
@@ -227,8 +292,9 @@ export default function NordModal({
const selectedServerId = methods.getValues('serverId');
const server = servers.find((s) => s.id === selectedServerId);
if (!server) return null;
const tech = server.technologies?.find((tt) => tt.id === 35);
const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
const publicKey = server.technologies
?.flatMap((technology) => technology.metadata ?? [])
.find((metadata) => metadata.name === 'public_key')?.value;
if (!publicKey) {
messageApi.error(t('pages.xray.nord.noPublicKey'));
return null;
@@ -249,32 +315,49 @@ export default function NordModal({
}
function addOutbound() {
if (selectedAlreadyAdded) return;
const ob = buildNordOutbound();
if (!ob) return;
const tag = typeof ob.tag === 'string' ? ob.tag : '';
if (tag && templateSettings?.outbounds?.some((outbound) => outbound?.tag === tag)) return;
onAddOutbound(ob);
messageApi.success(t('pages.xray.nord.outboundAdded'));
onClose();
}
function resetOutbound() {
if (nordOutboundIndex === -1) return;
const ob = buildNordOutbound();
if (!ob) return;
const oldTag = templateSettings?.outbounds?.[nordOutboundIndex]?.tag;
function resetOutbound(index: number) {
const existing = templateSettings?.outbounds?.[index];
if (
!existing?.tag?.startsWith?.('nord-') ||
!isResettableNordOutbound(existing) ||
!isRecord(existing.settings) ||
!nordData?.private_key
) {
return;
}
const ob = {
...existing,
settings: { ...existing.settings, secretKey: nordData.private_key },
};
onResetOutbound({
index: nordOutboundIndex,
index,
outbound: ob,
oldTag,
newTag: ob.tag as string,
oldTag: existing.tag,
newTag: existing.tag,
});
messageApi.success(t('pages.xray.nord.outboundUpdated'));
onClose();
}
return (
<>
{messageContextHolder}
<Modal open={open} title="NordVPN NordLynx" footer={null} onCancel={onClose}>
<Modal
open={open}
title="NordVPN NordLynx"
footer={null}
width={680}
className="nord-modal"
onCancel={onClose}
>
<FormProvider {...methods}>
{nordData == null ? (
<Tabs
@@ -284,18 +367,13 @@ export default function NordModal({
key: 'token',
label: t('pages.xray.nord.accessToken'),
children: (
<Form
colon={false}
labelCol={{ md: { span: 6 } }}
wrapperCol={{ md: { span: 18 } }}
className="mt-20"
>
<Form colon={false} layout="vertical" className="nord-login-form">
<FormField name="token" label={t('pages.xray.nord.accessToken')}>
<Input placeholder={t('pages.xray.nord.accessToken')} />
</FormField>
<Button
type="primary"
className="mt-10"
className="nord-login-action"
loading={loading}
icon={<LoginOutlined />}
onClick={login}
@@ -309,18 +387,13 @@ export default function NordModal({
key: 'key',
label: t('pages.xray.nord.privateKey'),
children: (
<Form
colon={false}
labelCol={{ md: { span: 6 } }}
wrapperCol={{ md: { span: 18 } }}
className="mt-20"
>
<Form colon={false} layout="vertical" className="nord-login-form">
<FormField name="manualKey" label={t('pages.xray.nord.privateKey')}>
<Input placeholder={t('pages.xray.nord.privateKey')} />
</FormField>
<Button
type="primary"
className="mt-10"
className="nord-login-action"
loading={loading}
icon={<SaveOutlined />}
onClick={saveKey}
@@ -334,109 +407,186 @@ export default function NordModal({
/>
) : (
<>
<table className="nord-data-table">
<tbody>
{nordData.token && (
<tr className="row-odd">
<td>{t('pages.xray.nord.accessToken')}</td>
<td>{nordData.token}</td>
<div className="nord-account-card">
<table className="nord-data-table">
<tbody>
{nordData.token && (
<tr>
<td>{t('pages.xray.nord.accessToken')}</td>
<td>{nordData.token}</td>
</tr>
)}
<tr>
<td>{t('pages.xray.nord.privateKey')}</td>
<td>{nordData.private_key}</td>
</tr>
</tbody>
</table>
<Button loading={loading} danger onClick={logout}>
{t('logout')}
</Button>
</div>
<Divider className="nord-section-divider">{t('pages.xray.warp.settings')}</Divider>
<Form colon={false} layout="vertical" className="nord-location-form">
<div className="nord-location-grid">
<FormField
name="countryId"
label={t('pages.xray.outbound.country')}
transform={{ input: (v) => v ?? undefined }}
onAfterChange={(v) => fetchServers(v as number)}
>
<Select
data-testid="nord-country-select"
showSearch={{ optionFilterProp: 'label' }}
options={countries.map((c) => {
const name = countryName(c.code, locale) || c.name || c.code;
const flag = countryFlag(c.code);
return {
value: c.id,
label: `${flag ? `${flag} ` : ''}${name} (${c.code})`,
};
})}
/>
</FormField>
{cities.length > 0 && (
<FormField name="cityId" label={t('pages.xray.outbound.city')}>
<Select
data-testid="nord-city-select"
showSearch={{ optionFilterProp: 'label' }}
options={[
{ value: null, label: t('pages.xray.outbound.allCities') },
...cities.map((c) => ({ value: c.id, label: c.name })),
]}
/>
</FormField>
)}
<tr>
<td>{t('pages.xray.nord.privateKey')}</td>
<td>{nordData.private_key}</td>
</tr>
</tbody>
</table>
<Button loading={loading} type="primary" danger className="mt-8" onClick={logout}>
{t('logout')}
</Button>
<Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
<Form
colon={false}
labelCol={{ md: { span: 6 } }}
wrapperCol={{ md: { span: 18 } }}
className="mt-10"
>
<FormField
name="countryId"
label={t('pages.xray.outbound.country')}
transform={{ input: (v) => v ?? undefined }}
onAfterChange={(v) => fetchServers(v as number)}
>
<Select
showSearch={{ optionFilterProp: 'label' }}
options={countries.map((c) => ({
value: c.id,
label: `${c.name} (${c.code})`,
}))}
/>
</FormField>
{cities.length > 0 && (
<FormField name="cityId" label={t('pages.xray.outbound.city')}>
<Select
showSearch={{ optionFilterProp: 'label' }}
options={[
{ value: null, label: t('pages.xray.outbound.allCities') },
...cities.map((c) => ({ value: c.id, label: c.name })),
]}
/>
</FormField>
)}
{filteredServers.length > 0 && (
<FormField name="serverId" label={t('pages.xray.outbound.server')}>
<Select
showSearch={{ optionFilterProp: 'label' }}
options={filteredServers.map((s) => ({
value: s.id,
label: `${s.cityName} ${s.name} ${s.hostname}`,
children: (
<span className="server-row">
<span className="server-name">
{s.cityName} - {s.name}
</span>
<Tag color={loadColor(s.load)} className="server-load-tag">
{s.load}%
</Tag>
</span>
),
}))}
/>
</FormField>
)}
{filteredServers.length > 0 && (
<div className="nord-server-field">
<FormField name="serverId" label={t('pages.xray.outbound.server')}>
<Select<number, NordServerOption>
data-testid="nord-server-select"
classNames={{ popup: { root: 'nord-server-popup' } }}
listHeight={320}
listItemHeight={58}
options={serverOptions}
showSearch={{
filterOption: (input, option) =>
option?.searchText.includes(input.trim().toLowerCase()) ?? false,
}}
optionRender={(option) => {
const server = option.data.server;
return (
<div className="nord-server-option">
<span className="nord-server-option-copy">
<span className="nord-server-option-name">{server.name}</span>
<span className="nord-server-option-meta">
<span>{server.cityName}</span>
<span aria-hidden="true">·</span>
<span className="nord-server-option-hostname">
{server.hostname}
</span>
<span aria-hidden="true">·</span>
<span className="nord-server-option-address">
{server.station}:51820
</span>
</span>
</span>
<span
className={`nord-server-load nord-server-load-${loadLevel(server.load)}`}
title={`${t('pages.xray.nord.serverLoad')}: ${server.load}%`}
>
<span className="nord-server-load-dot" aria-hidden="true" />
<span className="nord-server-load-label">
{t('pages.xray.nord.serverLoad')}
</span>
<span className="nord-server-load-value">{server.load}%</span>
</span>
</div>
);
}}
labelRender={() =>
selectedServer ? (
<span className="nord-selected-server">
<span className="nord-selected-server-name">
{selectedServer.name}
</span>
<span className="nord-selected-server-hostname">
{selectedServer.hostname}
</span>
<span className="nord-selected-server-address">
{selectedServer.station}:51820
</span>
<span
className={`nord-server-load nord-server-load-${loadLevel(selectedServer.load)}`}
title={`${t('pages.xray.nord.serverLoad')}: ${selectedServer.load}%`}
>
<span className="nord-server-load-dot" aria-hidden="true" />
<span className="nord-server-load-value">
{selectedServer.load}%
</span>
</span>
</span>
) : null
}
/>
</FormField>
</div>
)}
</div>
</Form>
<Divider className="my-10">{t('pages.xray.outbound.outboundStatus')}</Divider>
{nordOutboundIndex >= 0 ? (
<div className="nord-add-actions">
<div className="nord-already-added" aria-live="polite">
{selectedAlreadyAdded
? t('pages.xray.nord.alreadyAdded', { reset: t('reset') })
: null}
</div>
<Button
type="primary"
disabled={!serverId || selectedAlreadyAdded}
loading={loading}
onClick={addOutbound}
>
{t('pages.xray.warp.addOutbound')}
</Button>
</div>
{nordRows.length > 0 && (
<>
<Tag color="green">{t('enabled')}</Tag>
<Button
type="primary"
danger
loading={loading}
className="ml-8"
onClick={resetOutbound}
>
{t('reset')}
</Button>
</>
) : (
<>
<Tag color="orange">{t('disabled')}</Tag>
<Button
type="primary"
className="ml-8"
disabled={!serverId}
loading={loading}
onClick={addOutbound}
>
{t('pages.xray.warp.addOutbound')}
</Button>
<Divider className="nord-section-divider">
{t('pages.xray.nord.addedServers')}
</Divider>
<table className="nord-added-table" data-testid="nord-added-table">
<tbody>
{nordRows.map((row) => (
<tr key={`${row.index}-${row.tag}`}>
<td>
<span className="nord-added-server-tag">{row.tag}</span>
{row.endpoint && (
<span className="nord-added-server-endpoint">{row.endpoint}</span>
)}
</td>
<td>
<Button
type="primary"
danger
size="small"
loading={loading}
disabled={!row.resettable}
data-testid={`nord-reset-${row.index}`}
onClick={() => resetOutbound(row.index)}
>
{t('reset')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</>
+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' }] },
});
});
});