mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 23:27:14 +00:00
Move to TypeScript 7 and the oxc toolchain (oxlint + oxfmt) (#6262)
* chore(frontend,docs): move to TypeScript 7 and replace ESLint with oxlint
TypeScript 7 is the native Go port and ships no programmatic compiler
API, so typescript-eslint cannot run at all: it peer-pins
typescript >=4.8.4 <6.1.0 (canary too) and hard-crashes with
"typescript-eslint does not support TS 7.0". Upstream support is
tracked in typescript-eslint#10940 and targets TS >=7.1.
Rather than wait, or carry Microsoft's side-by-side alias (which keeps
a second TS 6 install alive purely to feed the linter), both projects
move to oxlint, which never depended on the TypeScript API.
Typecheck drops from ~9.7s to ~2.2s and 167 packages leave frontend/.
oxlint has no no-restricted-syntax, so the #6121/#6127 cleared-
InputNumber guard is reimplemented as a JS plugin in
frontend/tools/oxlint/. It was verified to still fire in
pages/settings/** and pages/xray/** and to stay exempt in *Modal.tsx.
The type-aware @deprecated sweep survives too, as
`npm run lint:deprecated`: oxlint's type-aware mode runs on
oxlint-tsgolint, which drives the TS 7 typescript-go checker, so the
TS 7 move is what makes it possible.
Behaviour is preserved rather than tightened. jsx-a11y/prefer-tag-over-role
is off in both configs because it was never part of the recommended sets
ESLint actually ran, and oxlint honours the existing eslint-disable
comments, so no source churn was needed.
Two real fixes fell out of the stricter linting:
- outbound-link-parser.test.ts used `out?.streamSettings` behind an `as`
cast, which hid the optional chain from ESLint and would throw on a
null parse; the rest of the file already used `out!`.
- InputAddon's conditional role/tabIndex/onKeyDown is genuinely
accessible but oxlint cannot evaluate it, so it gets a scoped disable.
* chore(docs): replace Prettier with oxfmt
oxfmt is the oxc project's Prettier-compatible formatter, so this pairs
with the oxlint move and drops the last JS-based tool from the docs
toolchain.
The swap is behaviour-preserving. Running Prettier and oxfmt over the
same files, with the existing .prettierrc.json settings migrated via
`oxfmt --migrate=prettier`, produces byte-identical output on every
file. (Comparing them outside the project directory is misleading:
Prettier silently falls back to its defaults when it cannot find its
config, which looks like a mismatch but is not one.)
The 18 files reformatted here were already failing `pnpm format:check`
before this change — Prettier wanted the exact same edits. The check is
not part of docs-ci.yml, which is why the drift went unnoticed.
.prettierignore becomes ignorePatterns in .oxfmtrc.json, keeping the
deliberate MDX exclusion: reflowing MDX prose merges headings into
paragraphs and collapses lists inside Steps/Callout components. Both
that and the generated fumadocs-openapi reference output were verified
untouched.
oxfmt is pinned to 0.63.0 rather than latest. pnpm 11's built-in
minimumReleaseAge policy rejects same-day releases, and 0.64.0 would
have made pnpm silently append 20 waiver lines to pnpm-workspace.yaml.
* style(frontend): adopt oxfmt and format src
frontend/ has never had a formatter, so this reformats 344 of 497 files
in src/. The change is purely whitespace, quoting and line wrapping —
no logic is touched. It is kept in its own commit so it does not bury
the TypeScript 7 / oxlint migration or the git blame for the code
itself.
Settings match docs/ and the code as it was already written: single
quotes, semicolons, trailing commas, 2-space indent, 100 columns. That
was measured rather than assumed — src/ was already uniformly
single-quoted and 2-space indented, with p90 line length at 75.
Formatting is scoped to src/ (mirroring `oxlint src`) and
.oxfmtrc.json ignores src/generated. Both matter: `make gen-check`
compares src/generated and public/openapi.json, and
`make msw-worker-check` byte-compares public/mockServiceWorker.js
against the installed MSW runtime, so reformatting any of them breaks
the gate.
Reflowing also moves `eslint-disable-next-line` comments off the line
they guard, which broke two suppressions that had been silently
correct before:
- clone-inbound-modal.test.tsx: the object literal became multi-line,
leaving `} as any;` four lines below its no-explicit-any disable.
- ClientsPage.tsx: the useMemo dependency array moved onto its own
line, out from under its exhaustive-deps disable.
Both comments were relocated onto the line they actually guard, and
verified to still suppress by removing them and watching the errors
return.
* ci: enforce formatting in CI and make verify
Adding oxfmt in the previous two commits gave both projects a formatter
but nothing that checks it, which is how docs/ had already drifted to 18
unformatted files: docs-ci.yml runs typecheck, lint, test and build, but
never format:check, so Prettier's complaints were only ever visible to
whoever ran it by hand.
Wire `format:check` into the frontend job in ci.yml and the docs job in
docs-ci.yml, and add a `format-check` target to `make verify` so the
local gate keeps mirroring CI as the Makefile header promises.
Verified the step actually bites rather than passing vacuously: adding
a badly formatted line to a source file in each project makes both
`make format-check` and `pnpm format:check` fail, and reverting it makes
them pass again.
No workflow referenced ESLint or Prettier by name — they all invoke the
package scripts — so the tooling swap needed no other CI changes.
* ci: trigger CI on Makefile changes
The path filters listed **.go, go.mod, go.sum, frontend/**, .nvmrc and
ci.yml itself, but not the Makefile — so a change to the canonical task
runner that ci.yml is meant to mirror could land without any job
running. The previous commit, which edits both, only triggers because
it happens to touch ci.yml too.
* fix(frontend): replace deprecated Ant Design 6 APIs in the geo components
`npm run lint:deprecated` reported five uses of props Ant Design 6 has
deprecated. All five are gone, and the matching runtime warnings no
longer appear in the test output.
Tag `bordered={false}` becomes `variant="filled"` and Space `direction`
becomes `orientation`; both are the one-to-one replacements named in
antd's own deprecation messages, and `direction`/`orientation` share the
same Orientation type.
Input `addonAfter` is the one that is not a rename. It becomes a
`Space.Compact block` wrapping the Input and the browse Button, which is
antd's documented migration. `block` keeps the field filling its form
row as the addon did. Note this is a deliberate visual change: the
button used to be a borderless `type="text"` icon sitting inside the
addon's grey box, and is now a regular button whose border joins the
input. The tooltip, aria-label, ref, id and onBlur wiring are unchanged,
so the react-hook-form binding in RuleFormModal and the existing tests
still address it the same way.
Only these five were deprecated. The other `bordered` props in the tree
sit on QRCode, Table, Descriptions and Alert, where the prop is not
deprecated, and these were the only two Space `direction` uses in the
codebase.
* fix(frontend): restore lint rules lost in the oxlint migration, and test the guard
Addresses the review on #6262.
The frontend config re-enabled only no-explicit-any and no-unused-vars
and left the rest of tseslint's recommended set to oxlint's correctness
category. It does not cover all of it. Confirmed by linting one probe
file against both configs: docs/ (which enumerates the rules) reports
all nine, frontend/ reported four. So ban-ts-comment,
no-empty-object-type, no-namespace, no-require-imports and
no-unsafe-function-type had silently stopped being enforced — a `//
@ts-ignore` or a `namespace` block would have landed unflagged. The ten
rules are now mirrored from docs/.oxlintrc.json, and src/ still passes.
The #6121/#6127 guard was 57 lines of hand-written AST walking with no
test. It now has one: fixtures for the three banned shapes plus an
onNumber()-wrapped control, asserting the rule fires three times and
that .oxlintrc.json still wires it to the right paths. Verified it fails
for the right reason by making walk() enumerate nothing, which is the
silent-death mode the review described — the traversal depends on
Object.keys() seeing AST children as own enumerable properties.
The fixtures deliberately violate the rule, so their oxlint config is
named guard.oxlintrc.json rather than .oxlintrc.json: oxlint discovers
nested configs by directory, which would otherwise turn the fixtures
into three lint errors. The test passes it explicitly with -c.
Also from the review:
- lint and format now cover tools/ as well as src/, so the one piece of
hand-written lint logic in the repo is no longer the least covered
file in it.
- lint-staged runs oxfmt before oxlint --fix. Formatting became a hard
CI gate in this PR while the hook only ran the linter, so a commit
could pass the hook and fail CI on formatting alone.
- .oxfmtrc.json ignores public/, so the artefacts that make gen-check
and make msw-worker-check byte-compare stay safe even if oxfmt is
invoked without a path argument.
- The MDX and generated-reference rationales that .prettierignore
carried are back as comments in docs/.oxfmtrc.json — oxlint and oxfmt
both accept JSONC, so relocating them was unnecessary.
Not applied: the review also suggested restoring ../internal/web/dist to
the ignore lists. Both tools reject `..` patterns outright ("patterns
are resolved within the config file's directory"), and being outside
frontend/ it is unreachable anyway.
This commit is contained in:
@@ -26,7 +26,9 @@ describe('API token creation date', () => {
|
||||
],
|
||||
});
|
||||
|
||||
render(<SecurityTab allSetting={{} as AllSetting} updateSetting={vi.fn()} saveSetting={vi.fn()} />);
|
||||
render(
|
||||
<SecurityTab allSetting={{} as AllSetting} updateSetting={vi.fn()} saveSetting={vi.fn()} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('tab', { name: /API Token/ }));
|
||||
|
||||
expect(await screen.findByText('seconds-token')).toBeTruthy();
|
||||
|
||||
@@ -62,7 +62,12 @@ describe('BalancerFormModal', () => {
|
||||
});
|
||||
|
||||
it('disables save and warns when the chosen fallback would create a balancer cycle', () => {
|
||||
const editing: BalancerFormValue = { tag: 'A', strategy: 'random', selector: ['proxy'], fallbackTag: 'B' };
|
||||
const editing: BalancerFormValue = {
|
||||
tag: 'A',
|
||||
strategy: 'random',
|
||||
selector: ['proxy'],
|
||||
fallbackTag: 'B',
|
||||
};
|
||||
const others: BalancerObject[] = [{ tag: 'B', selector: ['direct'], fallbackTag: 'A' }];
|
||||
const onConfirm = vi.fn();
|
||||
renderWithProviders(
|
||||
|
||||
@@ -87,7 +87,11 @@ describe('resolveLoopbackFallback', () => {
|
||||
});
|
||||
|
||||
const cases: Array<{ name: string; input: string; expected: string }> = [
|
||||
{ name: 'resolves a loopback tag through its routing rule', input: '_bl_bal1', expected: 'bal1' },
|
||||
{
|
||||
name: 'resolves a loopback tag through its routing rule',
|
||||
input: '_bl_bal1',
|
||||
expected: 'bal1',
|
||||
},
|
||||
{ name: 'returns a plain outbound tag unchanged', input: 'direct', expected: 'direct' },
|
||||
{ name: 'returns an empty tag unchanged', input: '', expected: '' },
|
||||
{ name: 'derives the balancer tag when no rule maps it', input: '_bl_bal2', expected: 'bal2' },
|
||||
@@ -161,9 +165,7 @@ describe('ensureBalancerLoopback rule ordering', () => {
|
||||
|
||||
ensureBalancerLoopback(settings, 'target');
|
||||
|
||||
expect(loopbackRuleIndex(settings, '_bl_target')).toBeLessThan(
|
||||
generalRuleIndex(settings),
|
||||
);
|
||||
expect(loopbackRuleIndex(settings, '_bl_target')).toBeLessThan(generalRuleIndex(settings));
|
||||
});
|
||||
|
||||
it('repositions an existing loopback rule that landed after a general rule', () => {
|
||||
@@ -215,9 +217,7 @@ describe('ensureBalancerLoopback rule ordering', () => {
|
||||
|
||||
ensureMissingBalancerLoopbacks(settings);
|
||||
|
||||
expect(loopbackRuleIndex(settings, '_bl_B2')).toBeLessThan(
|
||||
generalRuleIndex(settings),
|
||||
);
|
||||
expect(loopbackRuleIndex(settings, '_bl_B2')).toBeLessThan(generalRuleIndex(settings));
|
||||
});
|
||||
|
||||
it('keeps the loopback rule ahead of the general rule after a second ensureBalancerLoopback call', () => {
|
||||
@@ -229,9 +229,7 @@ describe('ensureBalancerLoopback rule ordering', () => {
|
||||
ensureBalancerLoopback(settings, 'target');
|
||||
ensureBalancerLoopback(settings, 'target');
|
||||
|
||||
expect(loopbackRuleIndex(settings, '_bl_target')).toBeLessThan(
|
||||
generalRuleIndex(settings),
|
||||
);
|
||||
expect(loopbackRuleIndex(settings, '_bl_target')).toBeLessThan(generalRuleIndex(settings));
|
||||
expect(
|
||||
ruleEntries(settings).filter(
|
||||
(r) => Array.isArray(r.inboundTag) && r.inboundTag.includes('_bl_target'),
|
||||
@@ -307,7 +305,9 @@ describe('propagateBalancerTagRename', () => {
|
||||
|
||||
it('leaves unrelated loopback tags untouched', () => {
|
||||
const settings = makeSettings({
|
||||
outbounds: [{ tag: '_bl_other', protocol: 'loopback', settings: { inboundTag: '_bl_other' } }],
|
||||
outbounds: [
|
||||
{ tag: '_bl_other', protocol: 'loopback', settings: { inboundTag: '_bl_other' } },
|
||||
],
|
||||
rules: [{ type: 'field', inboundTag: ['_bl_other'], balancerTag: 'other' }],
|
||||
balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_other' }],
|
||||
});
|
||||
|
||||
@@ -3,13 +3,20 @@ import { describe, expect, it } from 'vitest';
|
||||
import { syncObservatories } from '@/pages/xray/balancers/balancer-helpers';
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
|
||||
function tpl(routing: Record<string, unknown>, extra: Record<string, unknown> = {}): XraySettingsValue {
|
||||
function tpl(
|
||||
routing: Record<string, unknown>,
|
||||
extra: Record<string, unknown> = {},
|
||||
): XraySettingsValue {
|
||||
return { routing, ...extra } as unknown as XraySettingsValue;
|
||||
}
|
||||
|
||||
type ExpectedObserver = 'none' | 'observatory' | 'burstObservatory';
|
||||
|
||||
function expectObserver(t: XraySettingsValue, expected: ExpectedObserver, selectors: string[] = []) {
|
||||
function expectObserver(
|
||||
t: XraySettingsValue,
|
||||
expected: ExpectedObserver,
|
||||
selectors: string[] = [],
|
||||
) {
|
||||
if (expected === 'none') {
|
||||
expect(t.observatory).toBeUndefined();
|
||||
expect(t.burstObservatory).toBeUndefined();
|
||||
@@ -19,13 +26,17 @@ function expectObserver(t: XraySettingsValue, expected: ExpectedObserver, select
|
||||
if (expected === 'observatory') {
|
||||
expect(t.observatory).toBeDefined();
|
||||
expect(t.burstObservatory).toBeUndefined();
|
||||
expect(new Set((t.observatory as { subjectSelector: string[] }).subjectSelector)).toEqual(new Set(selectors));
|
||||
expect(new Set((t.observatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
|
||||
new Set(selectors),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
expect(t.observatory).toBeUndefined();
|
||||
expect(t.burstObservatory).toBeDefined();
|
||||
expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(new Set(selectors));
|
||||
expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
|
||||
new Set(selectors),
|
||||
);
|
||||
}
|
||||
|
||||
// Observatory sections have no reload API in xray-core, so creating one turns
|
||||
@@ -55,7 +66,14 @@ describe('syncObservatories', () => {
|
||||
},
|
||||
{
|
||||
name: 'roundRobin with fallback',
|
||||
balancers: [{ tag: 'rr', selector: ['rr-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } }],
|
||||
balancers: [
|
||||
{
|
||||
tag: 'rr',
|
||||
selector: ['rr-out'],
|
||||
fallbackTag: 'direct',
|
||||
strategy: { type: 'roundRobin' },
|
||||
},
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['rr-out'],
|
||||
},
|
||||
@@ -67,7 +85,9 @@ describe('syncObservatories', () => {
|
||||
},
|
||||
{
|
||||
name: 'leastPing with fallback',
|
||||
balancers: [{ tag: 'lp', selector: ['lp-out'], fallbackTag: 'direct', strategy: { type: 'leastPing' } }],
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['lp-out'], fallbackTag: 'direct', strategy: { type: 'leastPing' } },
|
||||
],
|
||||
expected: 'observatory' as const,
|
||||
selectors: ['lp-out'],
|
||||
},
|
||||
@@ -79,7 +99,9 @@ describe('syncObservatories', () => {
|
||||
},
|
||||
{
|
||||
name: 'leastLoad with fallback',
|
||||
balancers: [{ tag: 'll', selector: ['ll-out'], fallbackTag: 'direct', strategy: { type: 'leastLoad' } }],
|
||||
balancers: [
|
||||
{ tag: 'll', selector: ['ll-out'], fallbackTag: 'direct', strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['ll-out'],
|
||||
},
|
||||
@@ -129,7 +151,12 @@ describe('syncObservatories', () => {
|
||||
{
|
||||
name: 'roundRobin fallback + roundRobin without fallback',
|
||||
balancers: [
|
||||
{ tag: 'rrf', selector: ['rr-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } },
|
||||
{
|
||||
tag: 'rrf',
|
||||
selector: ['rr-fallback-out'],
|
||||
fallbackTag: 'direct',
|
||||
strategy: { type: 'roundRobin' },
|
||||
},
|
||||
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
@@ -175,7 +202,12 @@ describe('syncObservatories', () => {
|
||||
name: 'leastPing + roundRobin fallback',
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'rrf', selector: ['rr-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } },
|
||||
{
|
||||
tag: 'rrf',
|
||||
selector: ['rr-fallback-out'],
|
||||
fallbackTag: 'direct',
|
||||
strategy: { type: 'roundRobin' },
|
||||
},
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['lp-out', 'rr-fallback-out'],
|
||||
@@ -186,7 +218,12 @@ describe('syncObservatories', () => {
|
||||
{ tag: 'random', selector: ['random-out'] },
|
||||
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
|
||||
{ tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
|
||||
{ tag: 'rrf', selector: ['rr-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } },
|
||||
{
|
||||
tag: 'rrf',
|
||||
selector: ['rr-fallback-out'],
|
||||
fallbackTag: 'direct',
|
||||
strategy: { type: 'roundRobin' },
|
||||
},
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
@@ -217,20 +254,30 @@ describe('syncObservatories', () => {
|
||||
});
|
||||
|
||||
it('does not create burstObservatory for roundRobin without fallback', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'roundRobin' } }] });
|
||||
const t = tpl({
|
||||
balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'roundRobin' } }],
|
||||
});
|
||||
syncObservatories(t);
|
||||
expect(t.burstObservatory).toBeUndefined();
|
||||
});
|
||||
|
||||
it('creates burstObservatory for a random balancer with a fallbackTag (#5605)', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'OverProxy', selector: ['opera-proxy'], fallbackTag: 'warp' }] });
|
||||
const t = tpl({
|
||||
balancers: [{ tag: 'OverProxy', selector: ['opera-proxy'], fallbackTag: 'warp' }],
|
||||
});
|
||||
syncObservatories(t);
|
||||
expect(t.burstObservatory).toBeDefined();
|
||||
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['opera-proxy']);
|
||||
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual([
|
||||
'opera-proxy',
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates burstObservatory for roundRobin with a fallbackTag', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], fallbackTag: 'warp', strategy: { type: 'roundRobin' } }] });
|
||||
const t = tpl({
|
||||
balancers: [
|
||||
{ tag: 'b1', selector: ['a'], fallbackTag: 'warp', strategy: { type: 'roundRobin' } },
|
||||
],
|
||||
});
|
||||
syncObservatories(t);
|
||||
expect(t.burstObservatory).toBeDefined();
|
||||
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
|
||||
@@ -312,7 +359,12 @@ describe('syncObservatories', () => {
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['least-ping-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
|
||||
{ tag: 'rr', selector: ['round-robin-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } },
|
||||
{
|
||||
tag: 'rr',
|
||||
selector: ['round-robin-fallback-out'],
|
||||
fallbackTag: 'direct',
|
||||
strategy: { type: 'roundRobin' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ observatory: { subjectSelector: ['stale-least-ping-out'] } },
|
||||
@@ -326,7 +378,12 @@ describe('syncObservatories', () => {
|
||||
|
||||
it('does not keep no-fallback random selectors in an observer created by another balancer', () => {
|
||||
const t = tpl(
|
||||
{ balancers: [{ tag: 'b1', selector: ['a'] }, { tag: 'b2', selector: ['b'], strategy: { type: 'leastLoad' } }] },
|
||||
{
|
||||
balancers: [
|
||||
{ tag: 'b1', selector: ['a'] },
|
||||
{ tag: 'b2', selector: ['b'], strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
},
|
||||
{ burstObservatory: { subjectSelector: ['stale'] } },
|
||||
);
|
||||
syncObservatories(t);
|
||||
@@ -334,10 +391,13 @@ describe('syncObservatories', () => {
|
||||
});
|
||||
|
||||
it('removes observatories when no balancer can use them', () => {
|
||||
const t = tpl({ balancers: [] }, {
|
||||
observatory: { subjectSelector: ['a'] },
|
||||
burstObservatory: { subjectSelector: ['a'] },
|
||||
});
|
||||
const t = tpl(
|
||||
{ balancers: [] },
|
||||
{
|
||||
observatory: { subjectSelector: ['a'] },
|
||||
burstObservatory: { subjectSelector: ['a'] },
|
||||
},
|
||||
);
|
||||
syncObservatories(t);
|
||||
expect(t.observatory).toBeUndefined();
|
||||
expect(t.burstObservatory).toBeUndefined();
|
||||
|
||||
@@ -3,10 +3,10 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BalancerObjectSchema } from '@/schemas/routing';
|
||||
|
||||
const fixtures = import.meta.glob<unknown>(
|
||||
'./golden/fixtures/balancer/*.json',
|
||||
{ eager: true, import: 'default' },
|
||||
);
|
||||
const fixtures = import.meta.glob<unknown>('./golden/fixtures/balancer/*.json', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
});
|
||||
|
||||
function fixtureName(path: string): string {
|
||||
const file = path.split('/').pop() ?? path;
|
||||
@@ -15,7 +15,10 @@ function fixtureName(path: string): string {
|
||||
|
||||
describe('BalancerObjectSchema fixtures', () => {
|
||||
const entries = Object.entries(fixtures).sort(([a], [b]) => a.localeCompare(b));
|
||||
expect(entries.length, 'expected at least one fixture under golden/fixtures/balancer').toBeGreaterThan(0);
|
||||
expect(
|
||||
entries.length,
|
||||
'expected at least one fixture under golden/fixtures/balancer',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
for (const [path, raw] of entries) {
|
||||
it(`parses ${fixtureName(path)} byte-stably`, () => {
|
||||
|
||||
@@ -24,4 +24,4 @@ describe('ClientCardComment', () => {
|
||||
|
||||
expect(container.childElementCount).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,15 @@ describe('ClientFormModal — Vision flow preservation', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={qc}>
|
||||
<ClientFormModal open mode="edit" client={CLIENT} inbounds={[REALITY_INBOUND]} attachedIds={[4]} save={save} onOpenChange={() => {}} />
|
||||
<ClientFormModal
|
||||
open
|
||||
mode="edit"
|
||||
client={CLIENT}
|
||||
inbounds={[REALITY_INBOUND]}
|
||||
attachedIds={[4]}
|
||||
save={save}
|
||||
onOpenChange={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
@@ -53,7 +61,15 @@ describe('ClientFormModal — Vision flow preservation', () => {
|
||||
const tree = (inbounds: InboundOption[]) => (
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={qc}>
|
||||
<ClientFormModal open mode="edit" client={CLIENT} inbounds={inbounds} attachedIds={[4]} save={save} onOpenChange={() => {}} />
|
||||
<ClientFormModal
|
||||
open
|
||||
mode="edit"
|
||||
client={CLIENT}
|
||||
inbounds={inbounds}
|
||||
attachedIds={[4]}
|
||||
save={save}
|
||||
onOpenChange={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -24,15 +24,17 @@ function renderModal() {
|
||||
}
|
||||
|
||||
function openCredentialsTab() {
|
||||
const tab = Array.from(document.querySelectorAll('.ant-tabs-tab'))
|
||||
.find((t) => (t.textContent ?? '').trim() === 'Credentials');
|
||||
const tab = Array.from(document.querySelectorAll('.ant-tabs-tab')).find(
|
||||
(t) => (t.textContent ?? '').trim() === 'Credentials',
|
||||
);
|
||||
if (!tab) throw new Error('Credentials tab not found');
|
||||
fireEvent.click(tab);
|
||||
}
|
||||
|
||||
function tooltipIconForLabel(label: string): HTMLElement {
|
||||
const labelEl = Array.from(document.querySelectorAll('.ant-form-item-label label'))
|
||||
.find((l) => (l.textContent ?? '').trim() === label);
|
||||
const labelEl = Array.from(document.querySelectorAll('.ant-form-item-label label')).find(
|
||||
(l) => (l.textContent ?? '').trim() === label,
|
||||
);
|
||||
const item = labelEl?.closest('.ant-form-item') as HTMLElement | null;
|
||||
if (!item) throw new Error(`Form item not found for label: ${label}`);
|
||||
const tip = item.querySelector('.ant-form-item-tooltip') as HTMLElement | null;
|
||||
|
||||
@@ -99,11 +99,9 @@ describe('useClients query gating', () => {
|
||||
});
|
||||
|
||||
it('reports settingsReady even when the settings request fails, so the page can still render', async () => {
|
||||
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => new Msg(
|
||||
true,
|
||||
'',
|
||||
url.includes('/inbounds/options') ? [] : emptyPage,
|
||||
));
|
||||
vi.spyOn(HttpUtil, 'get').mockImplementation(
|
||||
async (url: string) => new Msg(true, '', url.includes('/inbounds/options') ? [] : emptyPage),
|
||||
);
|
||||
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(false, 'boom', null));
|
||||
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
|
||||
|
||||
|
||||
@@ -43,7 +43,12 @@ describe('clients table row cells', () => {
|
||||
{(doBump) => {
|
||||
bump = doBump;
|
||||
return (
|
||||
<ClientInboundChips ids={ids} inboundsById={proxy} protocolColors={PROTOCOL_COLORS} chipLimit={1} />
|
||||
<ClientInboundChips
|
||||
ids={ids}
|
||||
inboundsById={proxy}
|
||||
protocolColors={PROTOCOL_COLORS}
|
||||
chipLimit={1}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Harness>,
|
||||
@@ -66,8 +71,15 @@ describe('clients table row cells', () => {
|
||||
const [ids, setIds] = useState<number[]>([1]);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setIds([1, 2])}>swap</button>
|
||||
<ClientInboundChips ids={ids} inboundsById={proxy} protocolColors={PROTOCOL_COLORS} chipLimit={1} />
|
||||
<button type="button" onClick={() => setIds([1, 2])}>
|
||||
swap
|
||||
</button>
|
||||
<ClientInboundChips
|
||||
ids={ids}
|
||||
inboundsById={proxy}
|
||||
protocolColors={PROTOCOL_COLORS}
|
||||
chipLimit={1}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,9 +27,16 @@ describe('websocket payload identity preservation', () => {
|
||||
|
||||
describe('client summary always reflects the server, never a client_stats recompute (#6116)', () => {
|
||||
const serverSummary: ClientsSummary = {
|
||||
total: 3, active: 3,
|
||||
onlineCount: 0, depletedCount: 0, expiringCount: 0, deactiveCount: 0,
|
||||
online: [], depleted: [], expiring: [], deactive: [],
|
||||
total: 3,
|
||||
active: 3,
|
||||
onlineCount: 0,
|
||||
depletedCount: 0,
|
||||
expiringCount: 0,
|
||||
deactiveCount: 0,
|
||||
online: [],
|
||||
depleted: [],
|
||||
expiring: [],
|
||||
deactive: [],
|
||||
};
|
||||
|
||||
const pagedResponse = {
|
||||
|
||||
@@ -57,8 +57,9 @@ function openTargetDropdown() {
|
||||
}
|
||||
|
||||
function clickOption(text: string) {
|
||||
const option = Array.from(document.querySelectorAll('.ant-select-item-option'))
|
||||
.find((o) => (o.textContent ?? '').trim() === text);
|
||||
const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
|
||||
(o) => (o.textContent ?? '').trim() === text,
|
||||
);
|
||||
if (!option) throw new Error(`option '${text}' not found`);
|
||||
fireEvent.click(option);
|
||||
}
|
||||
@@ -70,8 +71,10 @@ function clickOk() {
|
||||
type PostBody = Record<string, unknown> & { nodeId?: number };
|
||||
const postedBodies = () => postSpy.mock.calls.map((c) => c[1] as PostBody);
|
||||
|
||||
const selectedTitles = () => Array.from(document.querySelectorAll('.ant-select-selection-item[title]'))
|
||||
.map((el) => el.getAttribute('title'));
|
||||
const selectedTitles = () =>
|
||||
Array.from(document.querySelectorAll('.ant-select-selection-item[title]')).map((el) =>
|
||||
el.getAttribute('title'),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
postSpy.mockClear();
|
||||
@@ -119,16 +122,19 @@ describe('CloneInboundModal', () => {
|
||||
renderModal();
|
||||
openTargetDropdown();
|
||||
|
||||
const option = (text: string) => Array.from(document.querySelectorAll('.ant-select-item-option'))
|
||||
.find((o) => (o.textContent ?? '').trim() === text);
|
||||
const option = (text: string) =>
|
||||
Array.from(document.querySelectorAll('.ant-select-item-option')).find(
|
||||
(o) => (o.textContent ?? '').trim() === text,
|
||||
);
|
||||
// Only `online` is selectable — `offline` and `unknown` (no heartbeat
|
||||
// yet) are both shown but disabled.
|
||||
expect(option('arm3 (offline)')?.className).toContain('ant-select-item-option-disabled');
|
||||
expect(option('arm5 (unknown)')?.className).toContain('ant-select-item-option-disabled');
|
||||
expect(option('arm2')?.className).not.toContain('ant-select-item-option-disabled');
|
||||
|
||||
const labels = Array.from(document.querySelectorAll('.ant-select-item-option'))
|
||||
.map((o) => (o.textContent ?? '').trim());
|
||||
const labels = Array.from(document.querySelectorAll('.ant-select-item-option')).map((o) =>
|
||||
(o.textContent ?? '').trim(),
|
||||
);
|
||||
expect(labels).toEqual(['Local panel', 'arm2', 'arm3 (offline)', 'arm5 (unknown)']);
|
||||
});
|
||||
|
||||
@@ -145,7 +151,9 @@ describe('CloneInboundModal', () => {
|
||||
// Clear all empties the selection and disables OK.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
|
||||
expect(selectedTitles()).toEqual([]);
|
||||
expect((screen.getByRole('button', { name: 'Clone' }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((screen.getByRole('button', { name: 'Clone' }) as HTMLButtonElement).disabled).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a cleared selection when the nodes list refetches mid-dialog', () => {
|
||||
@@ -201,8 +209,11 @@ describe('CloneInboundModal', () => {
|
||||
postSpy.mockImplementation(async (_url, data) => {
|
||||
const body = data as PostBody;
|
||||
if (body.nodeId === 2) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { success: false, msg: "port 23456 (tcp) already used by inbound 'x' (#1) on *" } as any;
|
||||
return {
|
||||
success: false,
|
||||
msg: "port 23456 (tcp) already used by inbound 'x' (#1) on *",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { success: true, obj: {} } as any;
|
||||
|
||||
@@ -36,28 +36,25 @@ describe('matchesFactoryDefault', () => {
|
||||
|
||||
describe('DefaultSettingTag', () => {
|
||||
it('shows the tag when the current value equals the shipped default, however it got there', () => {
|
||||
renderWithProviders(
|
||||
<DefaultSettingTag settingKey="subPort" value={2096} />,
|
||||
{ queryClient: clientWithDefaults({ subPort: '2096' }) },
|
||||
);
|
||||
renderWithProviders(<DefaultSettingTag settingKey="subPort" value={2096} />, {
|
||||
queryClient: clientWithDefaults({ subPort: '2096' }),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Default')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders nothing when the value differs from the default', () => {
|
||||
renderWithProviders(
|
||||
<DefaultSettingTag settingKey="subPort" value={8443} />,
|
||||
{ queryClient: clientWithDefaults({ subPort: '2096' }) },
|
||||
);
|
||||
renderWithProviders(<DefaultSettingTag settingKey="subPort" value={8443} />, {
|
||||
queryClient: clientWithDefaults({ subPort: '2096' }),
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Default')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders nothing while defaults are unknown', () => {
|
||||
renderWithProviders(
|
||||
<DefaultSettingTag settingKey="subPort" value={2096} />,
|
||||
{ queryClient: makeTestQueryClient() },
|
||||
);
|
||||
renderWithProviders(<DefaultSettingTag settingKey="subPort" value={2096} />, {
|
||||
queryClient: makeTestQueryClient(),
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Default')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -18,7 +18,9 @@ function withHosts(hosts: Record<string, string>): XraySettingsValue {
|
||||
describe('DnsTab', () => {
|
||||
it('keeps an empty row after adding a host', () => {
|
||||
function Harness() {
|
||||
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
|
||||
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(
|
||||
withHosts({ 'first.example': '1.1.1.1' }),
|
||||
);
|
||||
const updateTemplate: SetTemplate = (next) => {
|
||||
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
|
||||
};
|
||||
@@ -26,9 +28,7 @@ describe('DnsTab', () => {
|
||||
return <DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />;
|
||||
}
|
||||
|
||||
renderWithProviders(
|
||||
<Harness />,
|
||||
);
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Host$/ }));
|
||||
@@ -38,7 +38,9 @@ describe('DnsTab', () => {
|
||||
|
||||
it('keeps a row visible while its domain is incomplete', () => {
|
||||
function Harness() {
|
||||
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
|
||||
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(
|
||||
withHosts({ 'first.example': '1.1.1.1' }),
|
||||
);
|
||||
const updateTemplate: SetTemplate = (next) => {
|
||||
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
|
||||
};
|
||||
@@ -48,21 +50,30 @@ describe('DnsTab', () => {
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
|
||||
fireEvent.change(screen.getByLabelText('Domain (e.g. domain:example.com)'), { target: { value: '' } });
|
||||
fireEvent.change(screen.getByLabelText('Domain (e.g. domain:example.com)'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
|
||||
expect((screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value).toBe('');
|
||||
expect(
|
||||
(screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value,
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('shows hosts from an externally refreshed configuration', () => {
|
||||
function Harness() {
|
||||
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
|
||||
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(
|
||||
withHosts({ 'first.example': '1.1.1.1' }),
|
||||
);
|
||||
const updateTemplate: SetTemplate = (next) => {
|
||||
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setTemplateSettings(withHosts({ 'second.example': '2.2.2.2' }))}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTemplateSettings(withHosts({ 'second.example': '2.2.2.2' }))}
|
||||
>
|
||||
Refresh hosts
|
||||
</button>
|
||||
<DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />
|
||||
@@ -73,23 +84,33 @@ describe('DnsTab', () => {
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
|
||||
expect((screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value).toBe('first.example');
|
||||
expect(
|
||||
(screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value,
|
||||
).toBe('first.example');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh hosts' }));
|
||||
expect((screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value).toBe('second.example');
|
||||
expect(
|
||||
(screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value,
|
||||
).toBe('second.example');
|
||||
});
|
||||
|
||||
it('clears an incomplete host draft when DNS is disabled', () => {
|
||||
function Harness() {
|
||||
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
|
||||
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(
|
||||
withHosts({ 'first.example': '1.1.1.1' }),
|
||||
);
|
||||
const updateTemplate: SetTemplate = (next) => {
|
||||
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setTemplateSettings({})}>Disable DNS</button>
|
||||
<button type="button" onClick={() => setTemplateSettings(withHosts({}))}>Enable DNS</button>
|
||||
<button type="button" onClick={() => setTemplateSettings({})}>
|
||||
Disable DNS
|
||||
</button>
|
||||
<button type="button" onClick={() => setTemplateSettings(withHosts({}))}>
|
||||
Enable DNS
|
||||
</button>
|
||||
<DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />
|
||||
</>
|
||||
);
|
||||
@@ -97,7 +118,9 @@ describe('DnsTab', () => {
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
|
||||
fireEvent.change(screen.getByLabelText('Domain (e.g. domain:example.com)'), { target: { value: '' } });
|
||||
fireEvent.change(screen.getByLabelText('Domain (e.g. domain:example.com)'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Disable DNS' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enable DNS' }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
|
||||
|
||||
@@ -8,19 +8,21 @@ function fixtureName(path: string): string {
|
||||
return file.replace(/\.json$/, '');
|
||||
}
|
||||
|
||||
const dnsFixtures = import.meta.glob<unknown>(
|
||||
'./golden/fixtures/dns/*.json',
|
||||
{ eager: true, import: 'default' },
|
||||
);
|
||||
const dnsFixtures = import.meta.glob<unknown>('./golden/fixtures/dns/*.json', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
});
|
||||
|
||||
const serverFixtures = import.meta.glob<unknown>(
|
||||
'./golden/fixtures/dns-server/*.json',
|
||||
{ eager: true, import: 'default' },
|
||||
);
|
||||
const serverFixtures = import.meta.glob<unknown>('./golden/fixtures/dns-server/*.json', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
});
|
||||
|
||||
describe('DnsObjectSchema fixtures', () => {
|
||||
const entries = Object.entries(dnsFixtures).sort(([a], [b]) => a.localeCompare(b));
|
||||
expect(entries.length, 'expected at least one fixture under golden/fixtures/dns').toBeGreaterThan(0);
|
||||
expect(entries.length, 'expected at least one fixture under golden/fixtures/dns').toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
|
||||
for (const [path, raw] of entries) {
|
||||
it(`parses ${fixtureName(path)} byte-stably`, () => {
|
||||
@@ -32,7 +34,10 @@ describe('DnsObjectSchema fixtures', () => {
|
||||
|
||||
describe('DnsServerObjectSchema fixtures', () => {
|
||||
const entries = Object.entries(serverFixtures).sort(([a], [b]) => a.localeCompare(b));
|
||||
expect(entries.length, 'expected at least one fixture under golden/fixtures/dns-server').toBeGreaterThan(0);
|
||||
expect(
|
||||
entries.length,
|
||||
'expected at least one fixture under golden/fixtures/dns-server',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
for (const [path, raw] of entries) {
|
||||
it(`parses ${fixtureName(path)} byte-stably`, () => {
|
||||
@@ -69,8 +74,12 @@ describe('DnsServerObjectSchema port defaulting', () => {
|
||||
});
|
||||
|
||||
it('omits port for an h2c and h2c+local address', () => {
|
||||
expect(DnsServerObjectSchema.parse({ address: 'h2c://dns.example.com/dns-query' }).port).toBeUndefined();
|
||||
expect(DnsServerObjectSchema.parse({ address: 'h2c+local://dns.example.com/dns-query' }).port).toBeUndefined();
|
||||
expect(
|
||||
DnsServerObjectSchema.parse({ address: 'h2c://dns.example.com/dns-query' }).port,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
DnsServerObjectSchema.parse({ address: 'h2c+local://dns.example.com/dns-query' }).port,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits port for an uppercase encrypted scheme', () => {
|
||||
@@ -79,7 +88,10 @@ describe('DnsServerObjectSchema port defaulting', () => {
|
||||
});
|
||||
|
||||
it('preserves an explicit port on an encrypted address', () => {
|
||||
const parsed = DnsServerObjectSchema.parse({ address: 'https://dns.google/dns-query', port: 8443 });
|
||||
const parsed = DnsServerObjectSchema.parse({
|
||||
address: 'https://dns.google/dns-query',
|
||||
port: 8443,
|
||||
});
|
||||
expect(parsed.port).toBe(8443);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,10 @@ import { describe, expect, it } from 'vitest';
|
||||
import { migrateXmcSettings, parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
|
||||
import { FinalMaskStreamSettingsSchema } from '@/schemas/protocols/stream';
|
||||
|
||||
const fixtures = import.meta.glob<unknown>(
|
||||
'./golden/fixtures/finalmask/*.json',
|
||||
{ eager: true, import: 'default' },
|
||||
);
|
||||
const fixtures = import.meta.glob<unknown>('./golden/fixtures/finalmask/*.json', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
});
|
||||
|
||||
function fixtureName(path: string): string {
|
||||
const file = path.split('/').pop() ?? path;
|
||||
@@ -16,7 +16,10 @@ function fixtureName(path: string): string {
|
||||
|
||||
describe('FinalMaskStreamSettingsSchema fixtures', () => {
|
||||
const entries = Object.entries(fixtures).sort(([a], [b]) => a.localeCompare(b));
|
||||
expect(entries.length, 'expected at least one fixture under golden/fixtures/finalmask').toBeGreaterThan(0);
|
||||
expect(
|
||||
entries.length,
|
||||
'expected at least one fixture under golden/fixtures/finalmask',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
for (const [path, raw] of entries) {
|
||||
it(`parses ${fixtureName(path)} byte-stably`, () => {
|
||||
@@ -53,7 +56,12 @@ describe('migrateXmcSettings', () => {
|
||||
|
||||
it('leaves an already migrated mask untouched', () => {
|
||||
const profiles = [
|
||||
{ username: 'Notch', uuid: '069a79f4-44e9-4726-a5be-fca90e38aaf5', texturesValue: 'dmFsdWU=', texturesSignature: 'c2ln' },
|
||||
{
|
||||
username: 'Notch',
|
||||
uuid: '069a79f4-44e9-4726-a5be-fca90e38aaf5',
|
||||
texturesValue: 'dmFsdWU=',
|
||||
texturesSignature: 'c2ln',
|
||||
},
|
||||
];
|
||||
const { next, changed } = migrateXmcSettings({ hostname: '', password: 'pw', profiles });
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { formatInboundIssue, formatInboundValidation } from '@/pages/inbounds/form/formatValidationError';
|
||||
import {
|
||||
formatInboundIssue,
|
||||
formatInboundValidation,
|
||||
} from '@/pages/inbounds/form/formatValidationError';
|
||||
|
||||
const templates: Record<string, string> = {
|
||||
'pages.inbounds.toasts.invalidClientField': 'Client {client}: {field} — {reason}',
|
||||
@@ -40,7 +43,9 @@ describe('formatInboundValidation', () => {
|
||||
const parsed = schema.safeParse(values);
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) return;
|
||||
expect(formatInboundIssue(parsed.error.issues[0], values, t)).toContain('Client "broken@x.com": tgId — ');
|
||||
expect(formatInboundIssue(parsed.error.issues[0], values, t)).toContain(
|
||||
'Client "broken@x.com": tgId — ',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the index when the client has no email', () => {
|
||||
@@ -60,6 +65,8 @@ describe('formatInboundValidation', () => {
|
||||
{ path: ['port'], message: 'Invalid input' },
|
||||
];
|
||||
const values = { settings: { clients: [{ email: 'a@x.com' }] } };
|
||||
expect(formatInboundValidation(issues, values, t)).toBe('Client "a@x.com": tgId — Invalid input (+1 more)');
|
||||
expect(formatInboundValidation(issues, values, t)).toBe(
|
||||
'Client "a@x.com": tgId — Invalid input (+1 more)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,9 @@ describe('generated response examples', () => {
|
||||
});
|
||||
|
||||
it('pairs every example with a generated zod schema', () => {
|
||||
const missing = names.filter((name) => typeof registry[`${name}Schema`]?.safeParse !== 'function');
|
||||
const missing = names.filter(
|
||||
(name) => typeof registry[`${name}Schema`]?.safeParse !== 'function',
|
||||
);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,9 +13,17 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const FILES = [{ name: 'geosite.dat', kind: 'site', size: 1024, modifiedAt: 1785428467270, categories: 3 }];
|
||||
const FILES = [
|
||||
{ name: 'geosite.dat', kind: 'site', size: 1024, modifiedAt: 1785428467270, categories: 3 },
|
||||
];
|
||||
|
||||
const IP_FILE = { name: 'geoip.dat', kind: 'ip', size: 2048, modifiedAt: 1785428467270, categories: 1 };
|
||||
const IP_FILE = {
|
||||
name: 'geoip.dat',
|
||||
kind: 'ip',
|
||||
size: 2048,
|
||||
modifiedAt: 1785428467270,
|
||||
categories: 1,
|
||||
};
|
||||
|
||||
const IP_CATEGORIES = { total: 1, items: [{ code: 'private', entries: 1, attributes: [] }] };
|
||||
|
||||
@@ -29,15 +37,17 @@ const CATEGORIES = {
|
||||
};
|
||||
|
||||
function mockGeodata(files: unknown[] = FILES) {
|
||||
const get = vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string, params?: unknown) => {
|
||||
const requestedFile = (params as { file?: string } | undefined)?.file;
|
||||
if (url.includes('/geodata/files')) return new Msg(true, '', files);
|
||||
if (url.includes('/geodata/categories')) {
|
||||
return new Msg(true, '', requestedFile === 'geoip.dat' ? IP_CATEGORIES : CATEGORIES);
|
||||
}
|
||||
if (url.includes('/geodata/entries')) return new Msg(true, '', { total: 0, items: [] });
|
||||
return new Msg(true, '', null);
|
||||
});
|
||||
const get = vi
|
||||
.spyOn(HttpUtil, 'get')
|
||||
.mockImplementation(async (url: string, params?: unknown) => {
|
||||
const requestedFile = (params as { file?: string } | undefined)?.file;
|
||||
if (url.includes('/geodata/files')) return new Msg(true, '', files);
|
||||
if (url.includes('/geodata/categories')) {
|
||||
return new Msg(true, '', requestedFile === 'geoip.dat' ? IP_CATEGORIES : CATEGORIES);
|
||||
}
|
||||
if (url.includes('/geodata/entries')) return new Msg(true, '', { total: 0, items: [] });
|
||||
return new Msg(true, '', null);
|
||||
});
|
||||
vi.spyOn(HttpUtil, 'post').mockImplementation(async () => new Msg(true, '', []));
|
||||
return get;
|
||||
}
|
||||
@@ -65,17 +75,35 @@ describe('GeoBrowserModal selection', () => {
|
||||
it('seeds the selection from the field every time it opens', async () => {
|
||||
mockGeodata();
|
||||
const view = render(
|
||||
<GeoBrowserModal open kind="site" value="geosite:google" onApply={vi.fn()} onClose={vi.fn()} />,
|
||||
<GeoBrowserModal
|
||||
open
|
||||
kind="site"
|
||||
value="geosite:google"
|
||||
onApply={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
|
||||
|
||||
view.rerender(
|
||||
<GeoBrowserModal open={false} kind="site" value="geosite:google" onApply={vi.fn()} onClose={vi.fn()} />,
|
||||
<GeoBrowserModal
|
||||
open={false}
|
||||
kind="site"
|
||||
value="geosite:google"
|
||||
onApply={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
view.rerender(
|
||||
<GeoBrowserModal open kind="site" value="geosite:google" onApply={vi.fn()} onClose={vi.fn()} />,
|
||||
<GeoBrowserModal
|
||||
open
|
||||
kind="site"
|
||||
value="geosite:google"
|
||||
onApply={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
|
||||
@@ -86,7 +114,9 @@ describe('GeoBrowserModal selection', () => {
|
||||
mockGeodata();
|
||||
const user = userEvent.setup();
|
||||
const onApply = vi.fn();
|
||||
render(<GeoBrowserModal open kind="site" value="" onApply={onApply} onClose={vi.fn()} />, { wrapper });
|
||||
render(<GeoBrowserModal open kind="site" value="" onApply={onApply} onClose={vi.fn()} />, {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await user.click(await checkboxFor('google'));
|
||||
await user.type(screen.getByPlaceholderText(/search category|поиск категории/i), 'cn');
|
||||
@@ -97,7 +127,12 @@ describe('GeoBrowserModal selection', () => {
|
||||
|
||||
expect(onApply).toHaveBeenCalledTimes(1);
|
||||
const applied = String(onApply.mock.calls[0][0]);
|
||||
expect(applied.split(',').map((token) => token.trim()).sort()).toEqual(['geosite:cn', 'geosite:google']);
|
||||
expect(
|
||||
applied
|
||||
.split(',')
|
||||
.map((token) => token.trim())
|
||||
.sort(),
|
||||
).toEqual(['geosite:cn', 'geosite:google']);
|
||||
});
|
||||
|
||||
it('drops a category from the field when its checkbox is cleared', async () => {
|
||||
@@ -105,7 +140,13 @@ describe('GeoBrowserModal selection', () => {
|
||||
const user = userEvent.setup();
|
||||
const onApply = vi.fn();
|
||||
render(
|
||||
<GeoBrowserModal open kind="site" value="google.com, geosite:google, geosite:blabla" onApply={onApply} onClose={vi.fn()} />,
|
||||
<GeoBrowserModal
|
||||
open
|
||||
kind="site"
|
||||
value="google.com, geosite:google, geosite:blabla"
|
||||
onApply={onApply}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
@@ -118,7 +159,9 @@ describe('GeoBrowserModal selection', () => {
|
||||
|
||||
it('offers only databases matching the field kind', async () => {
|
||||
mockGeodata([...FILES, IP_FILE]);
|
||||
render(<GeoBrowserModal open kind="ip" value="" onApply={vi.fn()} onClose={vi.fn()} />, { wrapper });
|
||||
render(<GeoBrowserModal open kind="ip" value="" onApply={vi.fn()} onClose={vi.fn()} />, {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await screen.findByText('private');
|
||||
expect(screen.getByTitle('geoip.dat')).toBeTruthy();
|
||||
@@ -142,7 +185,9 @@ describe('GeoBrowserModal selection', () => {
|
||||
it('waits for the entry filter to settle instead of querying every keystroke', async () => {
|
||||
const get = mockGeodata();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(<GeoBrowserModal open kind="site" value="" onApply={vi.fn()} onClose={vi.fn()} />, { wrapper });
|
||||
render(<GeoBrowserModal open kind="site" value="" onApply={vi.fn()} onClose={vi.fn()} />, {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await user.click(await screen.findByText('cn'));
|
||||
await waitFor(() => expect(entryFilters(get)).toEqual(['']));
|
||||
@@ -156,7 +201,9 @@ describe('GeoBrowserModal selection', () => {
|
||||
it('drops the pending filter when another category is opened', async () => {
|
||||
const get = mockGeodata();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(<GeoBrowserModal open kind="site" value="" onApply={vi.fn()} onClose={vi.fn()} />, { wrapper });
|
||||
render(<GeoBrowserModal open kind="site" value="" onApply={vi.fn()} onClose={vi.fn()} />, {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await user.click(await screen.findByText('cn'));
|
||||
await user.type(screen.getByPlaceholderText('Filter inside category'), 'abcd');
|
||||
@@ -193,7 +240,9 @@ describe('GeoTokenInput validation feedback', () => {
|
||||
it('says the check failed instead of dropping the warnings silently', async () => {
|
||||
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', []));
|
||||
vi.spyOn(HttpUtil, 'post')
|
||||
.mockResolvedValueOnce(new Msg(true, '', [{ token: 'geosite:nope', reason: 'categoryMissing' }]))
|
||||
.mockResolvedValueOnce(
|
||||
new Msg(true, '', [{ token: 'geosite:nope', reason: 'categoryMissing' }]),
|
||||
)
|
||||
.mockResolvedValue(new Msg(false, 'too many tokens'));
|
||||
|
||||
const view = render(<GeoTokenInput kind="domain" value="geosite:nope" />, { wrapper });
|
||||
@@ -201,7 +250,11 @@ describe('GeoTokenInput validation feedback', () => {
|
||||
|
||||
view.rerender(<GeoTokenInput kind="domain" value="geosite:nope, geosite:other" />);
|
||||
|
||||
await screen.findByText('Could not check these values against the geo databases', {}, { timeout: 3000 });
|
||||
await screen.findByText(
|
||||
'Could not check these values against the geo databases',
|
||||
{},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
expect(screen.queryByText(/Not in the database/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,14 +8,23 @@ import {
|
||||
tokenFor,
|
||||
} from '@/lib/xray/geoTokens';
|
||||
|
||||
const siteKnown = new Set(['geosite:google', 'geosite:google@ads', 'geosite:cn', 'ext:my_rules.dat:corp']);
|
||||
const siteKnown = new Set([
|
||||
'geosite:google',
|
||||
'geosite:google@ads',
|
||||
'geosite:cn',
|
||||
'ext:my_rules.dat:corp',
|
||||
]);
|
||||
const ipKnown = new Set(['geoip:cn', 'geoip:private', 'ext:my_ips.dat:office']);
|
||||
|
||||
describe('parseTokens / formatTokens', () => {
|
||||
const cases: Array<[string, string, string[]]> = [
|
||||
['empty value', '', []],
|
||||
['single token', 'geosite:google', ['geosite:google']],
|
||||
['trims and drops blanks', ' geosite:google , , google.com ,', ['geosite:google', 'google.com']],
|
||||
[
|
||||
'trims and drops blanks',
|
||||
' geosite:google , , google.com ,',
|
||||
['geosite:google', 'google.com'],
|
||||
],
|
||||
['keeps negation', '!geoip:cn, 10.0.0.0/8', ['!geoip:cn', '10.0.0.0/8']],
|
||||
];
|
||||
|
||||
@@ -31,12 +40,36 @@ describe('parseTokens / formatTokens', () => {
|
||||
|
||||
describe('tokenFor', () => {
|
||||
const cases: Array<[string, string, string, 'site' | 'ip', string]> = [
|
||||
['default site database uses the geosite shorthand', 'geosite.dat', 'google', 'site', 'geosite:google'],
|
||||
[
|
||||
'default site database uses the geosite shorthand',
|
||||
'geosite.dat',
|
||||
'google',
|
||||
'site',
|
||||
'geosite:google',
|
||||
],
|
||||
['default ip database uses the geoip shorthand', 'geoip.dat', 'cn', 'ip', 'geoip:cn'],
|
||||
['custom site database falls back to ext', 'my_rules.dat', 'corp', 'site', 'ext:my_rules.dat:corp'],
|
||||
[
|
||||
'custom site database falls back to ext',
|
||||
'my_rules.dat',
|
||||
'corp',
|
||||
'site',
|
||||
'ext:my_rules.dat:corp',
|
||||
],
|
||||
['custom ip database falls back to ext', 'my_ips.dat', 'office', 'ip', 'ext:my_ips.dat:office'],
|
||||
['ip kind on the site database is not shorthand', 'geosite.dat', 'cn', 'ip', 'ext:geosite.dat:cn'],
|
||||
['site kind on the ip database is not shorthand', 'geoip.dat', 'cn', 'site', 'ext:geoip.dat:cn'],
|
||||
[
|
||||
'ip kind on the site database is not shorthand',
|
||||
'geosite.dat',
|
||||
'cn',
|
||||
'ip',
|
||||
'ext:geosite.dat:cn',
|
||||
],
|
||||
[
|
||||
'site kind on the ip database is not shorthand',
|
||||
'geoip.dat',
|
||||
'cn',
|
||||
'site',
|
||||
'ext:geoip.dat:cn',
|
||||
],
|
||||
];
|
||||
|
||||
it.each(cases)('%s', (_name, file, code, kind, expected) => {
|
||||
@@ -48,7 +81,12 @@ describe('selectionFromValue', () => {
|
||||
const cases: Array<[string, string, ReadonlySet<string>, string[]]> = [
|
||||
['empty value selects nothing', '', siteKnown, []],
|
||||
['plain values are not selectable', 'google.com, keyword:ads', siteKnown, []],
|
||||
['picks known tokens only', 'google.com, geosite:google, geosite:blabla', siteKnown, ['geosite:google']],
|
||||
[
|
||||
'picks known tokens only',
|
||||
'google.com, geosite:google, geosite:blabla',
|
||||
siteKnown,
|
||||
['geosite:google'],
|
||||
],
|
||||
[
|
||||
'keeps the value order',
|
||||
'geosite:cn, google.com, geosite:google',
|
||||
@@ -57,7 +95,12 @@ describe('selectionFromValue', () => {
|
||||
],
|
||||
['drops duplicates', 'geosite:google, geosite:google', siteKnown, ['geosite:google']],
|
||||
['attributes are distinct tokens', 'geosite:google@ads', siteKnown, ['geosite:google@ads']],
|
||||
['ext tokens are selectable', 'ext:my_rules.dat:corp, ext:other.dat:x', siteKnown, ['ext:my_rules.dat:corp']],
|
||||
[
|
||||
'ext tokens are selectable',
|
||||
'ext:my_rules.dat:corp, ext:other.dat:x',
|
||||
siteKnown,
|
||||
['ext:my_rules.dat:corp'],
|
||||
],
|
||||
['negated ip tokens stay unselected', '!geoip:cn, geoip:private', ipKnown, ['geoip:private']],
|
||||
];
|
||||
|
||||
@@ -161,7 +204,11 @@ describe('mergeSelection', () => {
|
||||
});
|
||||
|
||||
it('round-trips with selectionFromValue', () => {
|
||||
const value = mergeSelection('google.com, geosite:blabla', ['geosite:google', 'geosite:cn'], siteKnown);
|
||||
const value = mergeSelection(
|
||||
'google.com, geosite:blabla',
|
||||
['geosite:google', 'geosite:cn'],
|
||||
siteKnown,
|
||||
);
|
||||
expect(value).toBe('google.com, geosite:blabla, geosite:google, geosite:cn');
|
||||
expect(selectionFromValue(value, siteKnown)).toEqual(['geosite:google', 'geosite:cn']);
|
||||
});
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
{
|
||||
"address": "https://dns.google/dns-query",
|
||||
"port": 443,
|
||||
"domains": [
|
||||
"domain:google.com",
|
||||
"domain:youtube.com",
|
||||
"geosite:google"
|
||||
],
|
||||
"expectedIPs": [
|
||||
"geoip:us",
|
||||
"1.2.3.0/24"
|
||||
],
|
||||
"unexpectedIPs": [
|
||||
"geoip:private"
|
||||
],
|
||||
"domains": ["domain:google.com", "domain:youtube.com", "geosite:google"],
|
||||
"expectedIPs": ["geoip:us", "1.2.3.0/24"],
|
||||
"unexpectedIPs": ["geoip:private"],
|
||||
"skipFallback": false,
|
||||
"finalQuery": false,
|
||||
"tag": "google-doh",
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
{
|
||||
"servers": [
|
||||
"8.8.8.8",
|
||||
"1.1.1.1"
|
||||
]
|
||||
"servers": ["8.8.8.8", "1.1.1.1"]
|
||||
}
|
||||
|
||||
@@ -48,13 +48,8 @@
|
||||
{
|
||||
"type": "xdns",
|
||||
"settings": {
|
||||
"domains": [
|
||||
"example.com:txt",
|
||||
"example.org:a"
|
||||
],
|
||||
"resolvers": [
|
||||
"example.com:txt+udp://1.1.1.1:53"
|
||||
]
|
||||
"domains": ["example.com:txt", "example.org:a"],
|
||||
"resolvers": ["example.com:txt+udp://1.1.1.1:53"]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -68,10 +63,7 @@
|
||||
"type": "realm",
|
||||
"settings": {
|
||||
"url": "realm://public@example.com/my-realm",
|
||||
"stunServers": [
|
||||
"stun.l.google.com:19302",
|
||||
"global.stun.twilio.com:3478"
|
||||
]
|
||||
"stunServers": ["stun.l.google.com:19302", "global.stun.twilio.com:3478"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -11,12 +11,7 @@
|
||||
"tag": "inbound-hysteria",
|
||||
"sniffing": {
|
||||
"enabled": false,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic",
|
||||
"fakedns"
|
||||
],
|
||||
"destOverride": ["http", "tls", "quic", "fakedns"],
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false,
|
||||
"ipsExcluded": [],
|
||||
@@ -61,9 +56,7 @@
|
||||
"buildChain": false
|
||||
}
|
||||
],
|
||||
"alpn": [
|
||||
"h3"
|
||||
],
|
||||
"alpn": ["h3"],
|
||||
"echServerKeys": "",
|
||||
"settings": {
|
||||
"fingerprint": "chrome",
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
"protocol": "mixed",
|
||||
"settings": {
|
||||
"auth": "password",
|
||||
"accounts": [
|
||||
{ "user": "socksuser", "pass": "sockspass" }
|
||||
],
|
||||
"accounts": [{ "user": "socksuser", "pass": "sockspass" }],
|
||||
"udp": true,
|
||||
"ip": "127.0.0.1"
|
||||
}
|
||||
|
||||
@@ -7,47 +7,22 @@
|
||||
"regexp:^api\\.example\\.com$",
|
||||
"geosite:cn"
|
||||
],
|
||||
"ip": [
|
||||
"10.0.0.0/8",
|
||||
"geoip:cn",
|
||||
"geoip:private",
|
||||
"!geoip:cn"
|
||||
],
|
||||
"ip": ["10.0.0.0/8", "geoip:cn", "geoip:private", "!geoip:cn"],
|
||||
"port": "80,443,1000-2000",
|
||||
"sourcePort": "53",
|
||||
"localPort": "5353",
|
||||
"network": "tcp,udp",
|
||||
"sourceIP": [
|
||||
"192.168.0.0/16",
|
||||
"geoip:private"
|
||||
],
|
||||
"localIP": [
|
||||
"10.10.10.0/24"
|
||||
],
|
||||
"user": [
|
||||
"user@example.com",
|
||||
"regexp:^.+@admin\\..+$"
|
||||
],
|
||||
"sourceIP": ["192.168.0.0/16", "geoip:private"],
|
||||
"localIP": ["10.10.10.0/24"],
|
||||
"user": ["user@example.com", "regexp:^.+@admin\\..+$"],
|
||||
"vlessRoute": "443,8443",
|
||||
"inboundTag": [
|
||||
"inbound-1",
|
||||
"inbound-2"
|
||||
],
|
||||
"protocol": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic",
|
||||
"bittorrent"
|
||||
],
|
||||
"inboundTag": ["inbound-1", "inbound-2"],
|
||||
"protocol": ["http", "tls", "quic", "bittorrent"],
|
||||
"attrs": {
|
||||
"User-Agent": "regexp:^Mozilla.*",
|
||||
"Host": "example.com"
|
||||
},
|
||||
"process": [
|
||||
"chrome.exe",
|
||||
"curl",
|
||||
"self/"
|
||||
],
|
||||
"process": ["chrome.exe", "curl", "self/"],
|
||||
"outboundTag": "proxy-out",
|
||||
"ruleTag": "main-policy-rule",
|
||||
"webhook": {
|
||||
|
||||
@@ -29,18 +29,27 @@ describe('toHeaders', () => {
|
||||
const entryCases: Array<[string, HeaderEntry[]]> = [
|
||||
['empty', []],
|
||||
['single', [{ name: 'Host', value: 'example.test' }]],
|
||||
['duplicate name', [
|
||||
{ name: 'Accept', value: 'text/html' },
|
||||
{ name: 'Accept', value: 'application/json' },
|
||||
]],
|
||||
['empty name skipped', [
|
||||
{ name: '', value: 'ignored' },
|
||||
{ name: 'X-Real', value: 'kept' },
|
||||
]],
|
||||
['empty value skipped', [
|
||||
{ name: 'X-Empty', value: '' },
|
||||
{ name: 'X-Real', value: 'kept' },
|
||||
]],
|
||||
[
|
||||
'duplicate name',
|
||||
[
|
||||
{ name: 'Accept', value: 'text/html' },
|
||||
{ name: 'Accept', value: 'application/json' },
|
||||
],
|
||||
],
|
||||
[
|
||||
'empty name skipped',
|
||||
[
|
||||
{ name: '', value: 'ignored' },
|
||||
{ name: 'X-Real', value: 'kept' },
|
||||
],
|
||||
],
|
||||
[
|
||||
'empty value skipped',
|
||||
[
|
||||
{ name: 'X-Empty', value: '' },
|
||||
{ name: 'X-Real', value: 'kept' },
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
describe('toV2Headers (arr=true)', () => {
|
||||
@@ -72,7 +81,9 @@ describe('getHeaderValue lookups', () => {
|
||||
});
|
||||
|
||||
it('returns first value when the header is an array', () => {
|
||||
expect(getHeaderValue({ Accept: ['text/html', 'application/json'] }, 'accept')).toBe('text/html');
|
||||
expect(getHeaderValue({ Accept: ['text/html', 'application/json'] }, 'accept')).toBe(
|
||||
'text/html',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty string when the header has empty array', () => {
|
||||
|
||||
@@ -77,7 +77,9 @@ describe('httpRequest against the MSW-mocked network', () => {
|
||||
csrfHits += 1;
|
||||
return HttpResponse.json({ success: true, obj: CSRF_TOKEN });
|
||||
}),
|
||||
http.get(`${ORIGIN}/panel/api/status`, () => HttpResponse.json({ success: true, obj: { up: true } })),
|
||||
http.get(`${ORIGIN}/panel/api/status`, () =>
|
||||
HttpResponse.json({ success: true, obj: { up: true } }),
|
||||
),
|
||||
);
|
||||
|
||||
const res = await httpRequest('GET', '/panel/api/status');
|
||||
|
||||
@@ -32,7 +32,12 @@ describe('http-init fetch wrapper', () => {
|
||||
replaceMock = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { replace: replaceMock, href: 'http://localhost/', origin: 'http://localhost', pathname: '/' },
|
||||
value: {
|
||||
replace: replaceMock,
|
||||
href: 'http://localhost/',
|
||||
origin: 'http://localhost',
|
||||
pathname: '/',
|
||||
},
|
||||
});
|
||||
http = await import('@/api/http-init');
|
||||
});
|
||||
@@ -49,7 +54,9 @@ describe('http-init fetch wrapper', () => {
|
||||
await http.httpRequest('POST', '/panel/x', { a: 1, b: ['x', 'y'] });
|
||||
|
||||
expect(initOf().body).toBe('a=1&b=x&b=y');
|
||||
expect(headersOf().get('content-type')).toBe('application/x-www-form-urlencoded; charset=UTF-8');
|
||||
expect(headersOf().get('content-type')).toBe(
|
||||
'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('JSON-encodes bodies when the caller declares application/json', async () => {
|
||||
@@ -57,7 +64,12 @@ describe('http-init fetch wrapper', () => {
|
||||
http.setupHttp();
|
||||
fetchMock.mockResolvedValue(okEnvelope());
|
||||
|
||||
await http.httpRequest('POST', '/panel/x', { a: 1 }, { headers: { 'Content-Type': 'application/json' } });
|
||||
await http.httpRequest(
|
||||
'POST',
|
||||
'/panel/x',
|
||||
{ a: 1 },
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
|
||||
expect(initOf().body).toBe(JSON.stringify({ a: 1 }));
|
||||
expect(headersOf().get('content-type')).toBe('application/json');
|
||||
@@ -70,7 +82,9 @@ describe('http-init fetch wrapper', () => {
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('db', 'contents');
|
||||
await http.httpRequest('POST', '/panel/import', fd, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
await http.httpRequest('POST', '/panel/import', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
|
||||
expect(initOf().body).toBe(fd);
|
||||
expect(headersOf().has('content-type')).toBe(false);
|
||||
@@ -110,11 +124,7 @@ describe('http-init fetch wrapper', () => {
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.endsWith('/csrf-token')) return Promise.resolve(csrfResponse(`tok${dataCalls}`));
|
||||
dataCalls += 1;
|
||||
return Promise.resolve(
|
||||
dataCalls === 1
|
||||
? new Response('', { status: 403 })
|
||||
: okEnvelope(),
|
||||
);
|
||||
return Promise.resolve(dataCalls === 1 ? new Response('', { status: 403 }) : okEnvelope());
|
||||
});
|
||||
|
||||
const resp = await http.httpRequest('POST', '/panel/api/x', { a: 1 });
|
||||
@@ -133,7 +143,9 @@ describe('http-init fetch wrapper', () => {
|
||||
return Promise.resolve(new Response('', { status: 403 }));
|
||||
});
|
||||
|
||||
await expect(http.httpRequest('POST', '/panel/api/x', { a: 1 })).rejects.toBeInstanceOf(http.HttpError);
|
||||
await expect(http.httpRequest('POST', '/panel/api/x', { a: 1 })).rejects.toBeInstanceOf(
|
||||
http.HttpError,
|
||||
);
|
||||
expect(dataCalls).toBe(2);
|
||||
});
|
||||
|
||||
@@ -169,7 +181,9 @@ describe('http-init fetch wrapper', () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||
expect((await http.httpRequest('GET', '/b')).data).toBe('');
|
||||
|
||||
fetchMock.mockResolvedValueOnce(new Response('hello', { status: 200, headers: { 'content-type': 'text/plain' } }));
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response('hello', { status: 200, headers: { 'content-type': 'text/plain' } }),
|
||||
);
|
||||
expect((await http.httpRequest('GET', '/c')).data).toBe('hello');
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
@@ -231,7 +245,9 @@ describe('http-init fetch wrapper', () => {
|
||||
|
||||
await http.httpRequest('GET', '/x', undefined, { timeout: 20, signal: controller.signal });
|
||||
const signal = initOf().signal as AbortSignal;
|
||||
await new Promise<void>((resolve) => signal.addEventListener('abort', () => resolve(), { once: true }));
|
||||
await new Promise<void>((resolve) =>
|
||||
signal.addEventListener('abort', () => resolve(), { once: true }),
|
||||
);
|
||||
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
@@ -30,7 +30,12 @@ import { HttpError, httpRequest } from '@/api/http-init';
|
||||
import type { HttpResponse } from '@/api/http-init';
|
||||
|
||||
const mockRequest = vi.mocked(httpRequest);
|
||||
const envelope = (data: unknown): HttpResponse => ({ ok: true, status: 200, statusText: 'OK', data });
|
||||
const envelope = (data: unknown): HttpResponse => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data,
|
||||
});
|
||||
|
||||
describe('HttpUtil', () => {
|
||||
beforeEach(() => {
|
||||
@@ -49,7 +54,9 @@ describe('HttpUtil', () => {
|
||||
});
|
||||
|
||||
it('suppresses the success toast with silentSuccess but still warns on nodePending', async () => {
|
||||
mockRequest.mockResolvedValue(envelope({ success: true, msg: 'saved', obj: { nodePending: true } }));
|
||||
mockRequest.mockResolvedValue(
|
||||
envelope({ success: true, msg: 'saved', obj: { nodePending: true } }),
|
||||
);
|
||||
|
||||
await HttpUtil.post('/x', { a: 1 }, { silentSuccess: true });
|
||||
|
||||
@@ -75,7 +82,9 @@ describe('HttpUtil', () => {
|
||||
});
|
||||
|
||||
it('surfaces the backend error text from a thrown HttpError body (msg field)', async () => {
|
||||
mockRequest.mockRejectedValue(new HttpError(400, 'Bad Request', { success: false, msg: 'bad input' }));
|
||||
mockRequest.mockRejectedValue(
|
||||
new HttpError(400, 'Bad Request', { success: false, msg: 'bad input' }),
|
||||
);
|
||||
|
||||
const msg = await HttpUtil.post('/x', undefined, { silent: true });
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@ function flattenKeys(obj: Record<string, unknown>, prefix = ''): string[] {
|
||||
|
||||
function collectSources(dir: string, exts: string[], out: string[]): void {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (['node_modules', 'dist', 'generated', '.git', '.local', 'storybook-static'].includes(entry)) continue;
|
||||
if (['node_modules', 'dist', 'generated', '.git', '.local', 'storybook-static'].includes(entry))
|
||||
continue;
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
collectSources(full, exts, out);
|
||||
@@ -63,7 +64,9 @@ describe('i18n keys', () => {
|
||||
const tokens = new Set(blob.match(/[A-Za-z][A-Za-z0-9_.]*/g) ?? []);
|
||||
|
||||
const prefixes: string[] = [];
|
||||
for (const match of blob.matchAll(/['"`]([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+\.?)['"`]\s*\+/g)) {
|
||||
for (const match of blob.matchAll(
|
||||
/['"`]([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+\.?)['"`]\s*\+/g,
|
||||
)) {
|
||||
prefixes.push(match[1]);
|
||||
}
|
||||
for (const match of blob.matchAll(/[`']([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+\.?)\$\{/g)) {
|
||||
@@ -74,14 +77,18 @@ describe('i18n keys', () => {
|
||||
const dead = enKeys.filter(
|
||||
(key) => !tokens.has(key) && !prefixes.some((p) => key.startsWith(p)),
|
||||
);
|
||||
expect(dead, `dead i18n keys (delete from all 13 locales):\n ${dead.join('\n ')}`).toEqual([]);
|
||||
expect(dead, `dead i18n keys (delete from all 13 locales):\n ${dead.join('\n ')}`).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('every locale carries exactly the en-US key set', () => {
|
||||
const enSet = new Set(enKeys);
|
||||
for (const file of readdirSync(translationDir)) {
|
||||
if (!file.endsWith('.json') || file === 'en-US.json') continue;
|
||||
const keys = new Set(flattenKeys(JSON.parse(readFileSync(join(translationDir, file), 'utf8'))));
|
||||
const keys = new Set(
|
||||
flattenKeys(JSON.parse(readFileSync(join(translationDir, file), 'utf8'))),
|
||||
);
|
||||
const missing = enKeys.filter((k) => !keys.has(k));
|
||||
const orphans = [...keys].filter((k) => !enSet.has(k));
|
||||
expect(missing, `${file} is missing keys:\n ${missing.join('\n ')}`).toEqual([]);
|
||||
|
||||
@@ -15,7 +15,11 @@ function sourceInbound() {
|
||||
clients: [{ id: 'uuid-1', email: 'a@test', flow: 'xtls-rprx-vision' }],
|
||||
decryption: 'none',
|
||||
}),
|
||||
streamSettings: { network: 'tcp', security: 'reality', realitySettings: { dest: 'www.lovelive-anime.jp:443' } },
|
||||
streamSettings: {
|
||||
network: 'tcp',
|
||||
security: 'reality',
|
||||
realitySettings: { dest: 'www.lovelive-anime.jp:443' },
|
||||
},
|
||||
sniffing: { enabled: true },
|
||||
nodeId: 2,
|
||||
shareAddrStrategy: 'node',
|
||||
|
||||
@@ -18,10 +18,19 @@ import {
|
||||
} from '@/lib/xray/inbound-defaults';
|
||||
import { createHysteriaTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
|
||||
import { HttpInboundSettingsSchema } from '@/schemas/protocols/inbound/http';
|
||||
import { HysteriaClientSchema, HysteriaInboundSettingsSchema } from '@/schemas/protocols/inbound/hysteria';
|
||||
import {
|
||||
HysteriaClientSchema,
|
||||
HysteriaInboundSettingsSchema,
|
||||
} from '@/schemas/protocols/inbound/hysteria';
|
||||
import { MixedInboundSettingsSchema } from '@/schemas/protocols/inbound/mixed';
|
||||
import { ShadowsocksClientSchema, ShadowsocksInboundSettingsSchema } from '@/schemas/protocols/inbound/shadowsocks';
|
||||
import { TrojanClientSchema, TrojanInboundSettingsSchema } from '@/schemas/protocols/inbound/trojan';
|
||||
import {
|
||||
ShadowsocksClientSchema,
|
||||
ShadowsocksInboundSettingsSchema,
|
||||
} from '@/schemas/protocols/inbound/shadowsocks';
|
||||
import {
|
||||
TrojanClientSchema,
|
||||
TrojanInboundSettingsSchema,
|
||||
} from '@/schemas/protocols/inbound/trojan';
|
||||
import { TunnelInboundSettingsSchema } from '@/schemas/protocols/inbound/tunnel';
|
||||
import { VlessClientSchema, VlessInboundSettingsSchema } from '@/schemas/protocols/inbound/vless';
|
||||
import { VmessClientSchema, VmessInboundSettingsSchema } from '@/schemas/protocols/inbound/vmess';
|
||||
|
||||
@@ -38,19 +38,21 @@ const vlessRow: RawInboundRow = {
|
||||
tag: 'inbound-1',
|
||||
nodeId: null,
|
||||
settings: {
|
||||
clients: [{
|
||||
id: '8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02',
|
||||
email: 'alice@example.test',
|
||||
flow: '',
|
||||
limitIp: 0,
|
||||
totalGB: 0,
|
||||
expiryTime: 0,
|
||||
enable: true,
|
||||
tgId: 0,
|
||||
subId: 'abc123def',
|
||||
comment: '',
|
||||
reset: 0,
|
||||
}],
|
||||
clients: [
|
||||
{
|
||||
id: '8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02',
|
||||
email: 'alice@example.test',
|
||||
flow: '',
|
||||
limitIp: 0,
|
||||
totalGB: 0,
|
||||
expiryTime: 0,
|
||||
enable: true,
|
||||
tgId: 0,
|
||||
subId: 'abc123def',
|
||||
comment: '',
|
||||
reset: 0,
|
||||
},
|
||||
],
|
||||
decryption: 'none',
|
||||
encryption: 'none',
|
||||
fallbacks: [],
|
||||
@@ -168,7 +170,9 @@ describe('transportless streamSettings (wireguard / tunnel)', () => {
|
||||
sockopt: { tcpFastOpen: true },
|
||||
}),
|
||||
});
|
||||
const stream = values.streamSettings as { sockopt?: { tproxy?: string; tcpFastOpen?: boolean } };
|
||||
const stream = values.streamSettings as {
|
||||
sockopt?: { tproxy?: string; tcpFastOpen?: boolean };
|
||||
};
|
||||
expect(stream.sockopt?.tproxy).toBe('off');
|
||||
expect(stream.sockopt?.tcpFastOpen).toBe(true);
|
||||
});
|
||||
@@ -284,7 +288,9 @@ describe('formValuesToWirePayload', () => {
|
||||
});
|
||||
|
||||
it('defaults a missing monthly reset day to the first', () => {
|
||||
expect(rawInboundToFormValues({ ...vlessRow, trafficResetDay: undefined }).trafficResetDay).toBe(1);
|
||||
expect(
|
||||
rawInboundToFormValues({ ...vlessRow, trafficResetDay: undefined }).trafficResetDay,
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -376,7 +382,8 @@ describe('legacy xhttp session keys on edit (#5621)', () => {
|
||||
|
||||
it('rawInboundToFormValues lifts sessionPlacement/sessionKey onto the renamed keys', () => {
|
||||
const values = rawInboundToFormValues(legacyXhttpRow);
|
||||
const xhttp = (values.streamSettings as unknown as Record<string, Record<string, unknown>>).xhttpSettings;
|
||||
const xhttp = (values.streamSettings as unknown as Record<string, Record<string, unknown>>)
|
||||
.xhttpSettings;
|
||||
expect(xhttp.sessionIDPlacement).toBe('cookie');
|
||||
expect(xhttp.sessionIDKey).toBe('x_session');
|
||||
expect(xhttp.sessionPlacement).toBeUndefined();
|
||||
@@ -423,7 +430,8 @@ describe('xhttp xmux maxConcurrency survives a load/re-save round-trip', () => {
|
||||
|
||||
it('rawInboundToFormValues does not resurrect a non-zero maxConnections', () => {
|
||||
const values = rawInboundToFormValues(xmuxRow);
|
||||
const xhttp = (values.streamSettings as unknown as Record<string, Record<string, unknown>>).xhttpSettings;
|
||||
const xhttp = (values.streamSettings as unknown as Record<string, Record<string, unknown>>)
|
||||
.xhttpSettings;
|
||||
expect(xhttp.enableXmux).toBe(true);
|
||||
const xmux = xhttp.xmux as Record<string, unknown>;
|
||||
expect(xmux.maxConcurrency).toBe('1-2');
|
||||
|
||||
@@ -72,10 +72,9 @@ describe('inbound transport forms', () => {
|
||||
/* The inbound sockopt form shows only server/listening-side fields;
|
||||
outbound-only fields (dialerProxy, domainStrategy, interface,
|
||||
addressPortStrategy, happyEyeballs, tcpMptcp) live in the outbound form. */
|
||||
renderInForm(
|
||||
<SockoptForm toggleSockopt={noop} network="tcp" />,
|
||||
{ streamSettings: { sockopt: { mark: 0 } } },
|
||||
);
|
||||
renderInForm(<SockoptForm toggleSockopt={noop} network="tcp" />, {
|
||||
streamSettings: { sockopt: { mark: 0 } },
|
||||
});
|
||||
expect(fieldLabels()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,7 +113,9 @@ describe('InboundFormModal', () => {
|
||||
chooseSelectOption('protocol', proto);
|
||||
// Flush antd Form.useWatch('protocol') before reading — without it every iteration
|
||||
// sees the same pre-update DOM and the loop asserts nothing (the original bug here).
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
labelsByProto[proto] = fieldLabels();
|
||||
}
|
||||
|
||||
@@ -133,25 +135,27 @@ describe('InboundFormModal', () => {
|
||||
<InboundFormModal
|
||||
open
|
||||
mode="edit"
|
||||
dbInbound={new DBInbound({
|
||||
id: 1,
|
||||
port: 12345,
|
||||
listen: '',
|
||||
protocol: 'shadowsocks',
|
||||
remark: 'edge',
|
||||
enable: true,
|
||||
settings: {
|
||||
method: '2022-blake3-aes-128-gcm',
|
||||
password: 'server-password',
|
||||
network: 'tcp,udp',
|
||||
clients: [],
|
||||
},
|
||||
streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
|
||||
sniffing: { enabled: false },
|
||||
nodeId: null,
|
||||
shareAddrStrategy: 'custom',
|
||||
shareAddr: 'edge.example.test',
|
||||
})}
|
||||
dbInbound={
|
||||
new DBInbound({
|
||||
id: 1,
|
||||
port: 12345,
|
||||
listen: '',
|
||||
protocol: 'shadowsocks',
|
||||
remark: 'edge',
|
||||
enable: true,
|
||||
settings: {
|
||||
method: '2022-blake3-aes-128-gcm',
|
||||
password: 'server-password',
|
||||
network: 'tcp,udp',
|
||||
clients: [],
|
||||
},
|
||||
streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
|
||||
sniffing: { enabled: false },
|
||||
nodeId: null,
|
||||
shareAddrStrategy: 'custom',
|
||||
shareAddr: 'edge.example.test',
|
||||
})
|
||||
}
|
||||
dbInbounds={[]}
|
||||
availableNodes={[]}
|
||||
onClose={() => {}}
|
||||
@@ -165,20 +169,25 @@ describe('InboundFormModal', () => {
|
||||
|
||||
it('keeps the persisted node share strategy through the nodes-loading race (#5375)', async () => {
|
||||
const node = { id: 1, name: 'arm2', enable: true, status: 'online' } as never;
|
||||
const buildInbound = () => new DBInbound({
|
||||
id: 1,
|
||||
port: 23456,
|
||||
listen: '',
|
||||
protocol: 'vless',
|
||||
remark: 'noded',
|
||||
enable: true,
|
||||
settings: { clients: [] },
|
||||
streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
|
||||
sniffing: { enabled: false },
|
||||
nodeId: 1,
|
||||
shareAddrStrategy: 'node',
|
||||
});
|
||||
const flush = async () => { await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); };
|
||||
const buildInbound = () =>
|
||||
new DBInbound({
|
||||
id: 1,
|
||||
port: 23456,
|
||||
listen: '',
|
||||
protocol: 'vless',
|
||||
remark: 'noded',
|
||||
enable: true,
|
||||
settings: { clients: [] },
|
||||
streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
|
||||
sniffing: { enabled: false },
|
||||
nodeId: 1,
|
||||
shareAddrStrategy: 'node',
|
||||
});
|
||||
const flush = async () => {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
};
|
||||
const strategyItem = (title: string) =>
|
||||
document.querySelector(`.ant-select-content[title="${title}"]`);
|
||||
const modal = (nodes: never[], fetched: boolean) => (
|
||||
|
||||
@@ -8,11 +8,7 @@ import {
|
||||
genWireguardLinks,
|
||||
getInboundClients,
|
||||
} from '@/lib/xray/inbound-link';
|
||||
import {
|
||||
canEnableTlsFlow,
|
||||
isSS2022,
|
||||
isSSMultiUser,
|
||||
} from '@/lib/xray/protocol-capabilities';
|
||||
import { canEnableTlsFlow, isSS2022, isSSMultiUser } from '@/lib/xray/protocol-capabilities';
|
||||
|
||||
const FALLBACK_HOST = 'panel.example.test';
|
||||
|
||||
@@ -134,7 +130,11 @@ describe('inboundFromDb', () => {
|
||||
{ password: 'pw2', email: 'two@test' },
|
||||
],
|
||||
},
|
||||
streamSettings: { network: 'tcp', security: 'tls', tlsSettings: { serverName: 'example.test' } },
|
||||
streamSettings: {
|
||||
network: 'tcp',
|
||||
security: 'tls',
|
||||
tlsSettings: { serverName: 'example.test' },
|
||||
},
|
||||
};
|
||||
const inbound = inboundFromDb(raw);
|
||||
const entries = genAllLinks({
|
||||
@@ -150,64 +150,86 @@ describe('inboundFromDb', () => {
|
||||
|
||||
describe('protocol-capability helpers with raw coerced shapes', () => {
|
||||
it('isSSMultiUser returns true for legacy SS methods', () => {
|
||||
expect(isSSMultiUser({ protocol: 'shadowsocks', settings: { method: 'aes-256-gcm' } })).toBe(true);
|
||||
expect(isSSMultiUser({ protocol: 'shadowsocks', settings: { method: '2022-blake3-aes-128-gcm' } })).toBe(true);
|
||||
expect(isSSMultiUser({ protocol: 'shadowsocks', settings: { method: 'aes-256-gcm' } })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isSSMultiUser({ protocol: 'shadowsocks', settings: { method: '2022-blake3-aes-128-gcm' } }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('isSSMultiUser returns false for single-user blake3-chacha20 method', () => {
|
||||
expect(isSSMultiUser({
|
||||
protocol: 'shadowsocks',
|
||||
settings: { method: '2022-blake3-chacha20-poly1305' },
|
||||
})).toBe(false);
|
||||
expect(
|
||||
isSSMultiUser({
|
||||
protocol: 'shadowsocks',
|
||||
settings: { method: '2022-blake3-chacha20-poly1305' },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('isSS2022 detects 2022-blake3 family', () => {
|
||||
expect(isSS2022({ protocol: 'shadowsocks', settings: { method: '2022-blake3-aes-128-gcm' } })).toBe(true);
|
||||
expect(
|
||||
isSS2022({ protocol: 'shadowsocks', settings: { method: '2022-blake3-aes-128-gcm' } }),
|
||||
).toBe(true);
|
||||
expect(isSS2022({ protocol: 'shadowsocks', settings: { method: 'aes-256-gcm' } })).toBe(false);
|
||||
});
|
||||
|
||||
it('canEnableTlsFlow gates on vless + tcp + tls/reality', () => {
|
||||
expect(canEnableTlsFlow({
|
||||
protocol: 'vless',
|
||||
streamSettings: { network: 'tcp', security: 'tls' },
|
||||
})).toBe(true);
|
||||
expect(canEnableTlsFlow({
|
||||
protocol: 'vless',
|
||||
streamSettings: { network: 'ws', security: 'tls' },
|
||||
})).toBe(false);
|
||||
expect(canEnableTlsFlow({
|
||||
protocol: 'vmess',
|
||||
streamSettings: { network: 'tcp', security: 'tls' },
|
||||
})).toBe(false);
|
||||
expect(
|
||||
canEnableTlsFlow({
|
||||
protocol: 'vless',
|
||||
streamSettings: { network: 'tcp', security: 'tls' },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canEnableTlsFlow({
|
||||
protocol: 'vless',
|
||||
streamSettings: { network: 'ws', security: 'tls' },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canEnableTlsFlow({
|
||||
protocol: 'vmess',
|
||||
streamSettings: { network: 'tcp', security: 'tls' },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('canEnableTlsFlow allows vless + xhttp when vlessenc encryption is set', () => {
|
||||
const enc = 'mlkem768x25519plus.native.0rtt.G3cdPSd1-NnlpTbWNSM5vHsT5VNzWfFzYSKwbUMnV1Y';
|
||||
const dec = 'mlkem768x25519plus.native.600s.mMFxPe7lz5xoq2qBk22cQYefu5fpc_2dGR8lMOKem0E';
|
||||
// XHTTP + a real (generated) encryption value → Vision flow allowed.
|
||||
expect(canEnableTlsFlow({
|
||||
protocol: 'vless',
|
||||
settings: { encryption: enc },
|
||||
streamSettings: { network: 'xhttp', security: 'none' },
|
||||
})).toBe(true);
|
||||
expect(
|
||||
canEnableTlsFlow({
|
||||
protocol: 'vless',
|
||||
settings: { encryption: enc },
|
||||
streamSettings: { network: 'xhttp', security: 'none' },
|
||||
}),
|
||||
).toBe(true);
|
||||
// decryption alone (server-side value) is enough on XHTTP.
|
||||
expect(canEnableTlsFlow({
|
||||
protocol: 'vless',
|
||||
settings: { decryption: dec, encryption: 'none' },
|
||||
streamSettings: { network: 'xhttp', security: 'none' },
|
||||
})).toBe(true);
|
||||
expect(
|
||||
canEnableTlsFlow({
|
||||
protocol: 'vless',
|
||||
settings: { decryption: dec, encryption: 'none' },
|
||||
streamSettings: { network: 'xhttp', security: 'none' },
|
||||
}),
|
||||
).toBe(true);
|
||||
// No encryption → stays gated off.
|
||||
expect(canEnableTlsFlow({
|
||||
protocol: 'vless',
|
||||
settings: { encryption: 'none' },
|
||||
streamSettings: { network: 'xhttp', security: 'none' },
|
||||
})).toBe(false);
|
||||
expect(
|
||||
canEnableTlsFlow({
|
||||
protocol: 'vless',
|
||||
settings: { encryption: 'none' },
|
||||
streamSettings: { network: 'xhttp', security: 'none' },
|
||||
}),
|
||||
).toBe(false);
|
||||
// vlessenc is XHTTP-only: TCP without tls/reality is not Vision-capable.
|
||||
expect(canEnableTlsFlow({
|
||||
protocol: 'vless',
|
||||
settings: { decryption: dec, encryption: enc },
|
||||
streamSettings: { network: 'tcp', security: 'none' },
|
||||
})).toBe(false);
|
||||
expect(
|
||||
canEnableTlsFlow({
|
||||
protocol: 'vless',
|
||||
settings: { decryption: dec, encryption: enc },
|
||||
streamSettings: { network: 'tcp', security: 'none' },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ import { InboundSchema } from '@/schemas/api/inbound';
|
||||
// round-trip. These fixtures are the input the link generators in
|
||||
// lib/xray/inbound-link.ts will consume once extracted.
|
||||
|
||||
const fixtures = import.meta.glob<unknown>(
|
||||
'./golden/fixtures/inbound-full/*.json',
|
||||
{ eager: true, import: 'default' },
|
||||
);
|
||||
const fixtures = import.meta.glob<unknown>('./golden/fixtures/inbound-full/*.json', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
});
|
||||
|
||||
function fixtureName(path: string): string {
|
||||
const file = path.split('/').pop() ?? path;
|
||||
@@ -20,7 +20,10 @@ function fixtureName(path: string): string {
|
||||
|
||||
describe('InboundSchema (full) fixtures', () => {
|
||||
const entries = Object.entries(fixtures).sort(([a], [b]) => a.localeCompare(b));
|
||||
expect(entries.length, 'expected at least one fixture under golden/fixtures/inbound-full').toBeGreaterThan(0);
|
||||
expect(
|
||||
entries.length,
|
||||
'expected at least one fixture under golden/fixtures/inbound-full',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
for (const [path, raw] of entries) {
|
||||
it(`parses ${fixtureName(path)} byte-stably`, () => {
|
||||
|
||||
@@ -22,10 +22,10 @@ import type { WireguardInboundSettings } from '@/schemas/protocols/inbound/wireg
|
||||
// generator was verified byte-equal to the corresponding legacy Inbound
|
||||
// class method. Future drift past this baseline is a regression.
|
||||
|
||||
const fullFixtures = import.meta.glob<unknown>(
|
||||
'./golden/fixtures/inbound-full/*.json',
|
||||
{ eager: true, import: 'default' },
|
||||
);
|
||||
const fullFixtures = import.meta.glob<unknown>('./golden/fixtures/inbound-full/*.json', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
});
|
||||
|
||||
function fixtureName(path: string): string {
|
||||
const file = path.split('/').pop() ?? path;
|
||||
@@ -35,7 +35,10 @@ function fixtureName(path: string): string {
|
||||
function fixturesForProtocol(protocol: string): Array<[string, Record<string, unknown>]> {
|
||||
return Object.entries(fullFixtures)
|
||||
.filter(([, raw]) => (raw as { protocol?: string }).protocol === protocol)
|
||||
.map(([path, raw]): [string, Record<string, unknown>] => [fixtureName(path), raw as Record<string, unknown>])
|
||||
.map(([path, raw]): [string, Record<string, unknown>] => [
|
||||
fixtureName(path),
|
||||
raw as Record<string, unknown>,
|
||||
])
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
@@ -46,7 +49,8 @@ describe('genVmessLink', () => {
|
||||
for (const [name, raw] of fixtures) {
|
||||
it(`${name}: byte-stable`, () => {
|
||||
const typed = InboundSchema.parse(raw);
|
||||
const settings = (raw as { settings: { clients: Array<{ id: string; security?: string }> } }).settings;
|
||||
const settings = (raw as { settings: { clients: Array<{ id: string; security?: string }> } })
|
||||
.settings;
|
||||
const client = settings.clients[0];
|
||||
|
||||
const link = genVmessLink({
|
||||
@@ -71,7 +75,8 @@ describe('genVlessLink', () => {
|
||||
for (const [name, raw] of fixtures) {
|
||||
it(`${name}: byte-stable`, () => {
|
||||
const typed = InboundSchema.parse(raw);
|
||||
const settings = (raw as { settings: { clients: Array<{ id: string; flow?: string }> } }).settings;
|
||||
const settings = (raw as { settings: { clients: Array<{ id: string; flow?: string }> } })
|
||||
.settings;
|
||||
const client = settings.clients[0];
|
||||
|
||||
const link = genVlessLink({
|
||||
@@ -118,7 +123,13 @@ describe('genVlessLink vlessRoute', () => {
|
||||
remark: 'r',
|
||||
clientId: '11111111-2222-4333-8444-555555555555',
|
||||
flow: '' as never,
|
||||
externalProxy: { forceTls: 'same', dest: 'example.test', port: typed.port, remark: '', vlessRoute: '443' },
|
||||
externalProxy: {
|
||||
forceTls: 'same',
|
||||
dest: 'example.test',
|
||||
port: typed.port,
|
||||
remark: '',
|
||||
vlessRoute: '443',
|
||||
},
|
||||
});
|
||||
expect(link).toContain('vless://11111111-2222-01bb-8444-555555555555@');
|
||||
});
|
||||
@@ -214,7 +225,8 @@ describe('genHysteriaLink', () => {
|
||||
const [, raw] = fixtures[0];
|
||||
const base64Pin = 'yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ=';
|
||||
const hexPin = '84491c0312d9e70f519ce24659a2ca7d9c4ec59dc86417ece426945e0f939293';
|
||||
const colonPin = 'C8:47:DD:23:95:D0:97:8C:07:80:B8:20:1C:4B:28:9A:8B:28:15:97:D4:7C:27:5F:2D:77:D3:F9:6D:8D:E9:C4';
|
||||
const colonPin =
|
||||
'C8:47:DD:23:95:D0:97:8C:07:80:B8:20:1C:4B:28:9A:8B:28:15:97:D4:7C:27:5F:2D:77:D3:F9:6D:8D:E9:C4';
|
||||
const stream = raw.streamSettings as Record<string, unknown>;
|
||||
const tls = stream.tlsSettings as Record<string, unknown>;
|
||||
const tlsClientSettings = tls.settings as Record<string, unknown>;
|
||||
@@ -353,99 +365,128 @@ describe('resolveAddr precedence', () => {
|
||||
};
|
||||
|
||||
it('prefers hostOverride over listen and fallback', () => {
|
||||
expect(resolveAddr(
|
||||
{ ...baseInbound, listen: '10.0.0.1' } as never,
|
||||
'cdn.example.test',
|
||||
'fallback.test',
|
||||
)).toBe('cdn.example.test');
|
||||
expect(
|
||||
resolveAddr(
|
||||
{ ...baseInbound, listen: '10.0.0.1' } as never,
|
||||
'cdn.example.test',
|
||||
'fallback.test',
|
||||
),
|
||||
).toBe('cdn.example.test');
|
||||
});
|
||||
|
||||
it('uses listen when override is empty and listen is explicit', () => {
|
||||
expect(resolveAddr(
|
||||
{ ...baseInbound, listen: '10.0.0.1' } as never,
|
||||
'',
|
||||
'fallback.test',
|
||||
)).toBe('10.0.0.1');
|
||||
expect(resolveAddr({ ...baseInbound, listen: '10.0.0.1' } as never, '', 'fallback.test')).toBe(
|
||||
'10.0.0.1',
|
||||
);
|
||||
});
|
||||
|
||||
it('skips listen when it is 0.0.0.0 and falls through to fallbackHostname', () => {
|
||||
expect(resolveAddr(
|
||||
{ ...baseInbound, listen: '0.0.0.0' } as never,
|
||||
'',
|
||||
expect(resolveAddr({ ...baseInbound, listen: '0.0.0.0' } as never, '', 'fallback.test')).toBe(
|
||||
'fallback.test',
|
||||
)).toBe('fallback.test');
|
||||
);
|
||||
});
|
||||
|
||||
it('skips a unix socket path listen and falls through to fallbackHostname', () => {
|
||||
expect(resolveAddr(
|
||||
{ ...baseInbound, listen: '/run/xray/in.sock' } as never,
|
||||
'',
|
||||
'fallback.test',
|
||||
)).toBe('fallback.test');
|
||||
expect(resolveAddr(
|
||||
{ ...baseInbound, listen: '@xray-abstract' } as never,
|
||||
'',
|
||||
'fallback.test',
|
||||
)).toBe('fallback.test');
|
||||
expect(
|
||||
resolveAddr({ ...baseInbound, listen: '/run/xray/in.sock' } as never, '', 'fallback.test'),
|
||||
).toBe('fallback.test');
|
||||
expect(
|
||||
resolveAddr({ ...baseInbound, listen: '@xray-abstract' } as never, '', 'fallback.test'),
|
||||
).toBe('fallback.test');
|
||||
});
|
||||
|
||||
it('falls through to fallbackHostname when listen is empty', () => {
|
||||
expect(resolveAddr(
|
||||
baseInbound as never,
|
||||
'',
|
||||
'fallback.test',
|
||||
)).toBe('fallback.test');
|
||||
expect(resolveAddr(baseInbound as never, '', 'fallback.test')).toBe('fallback.test');
|
||||
});
|
||||
|
||||
it('uses listen strategy with a shareable IPv6 listen before node override', () => {
|
||||
expect(resolveAddr(
|
||||
{ ...baseInbound, listen: '[2001:db8::1]', shareAddrStrategy: 'listen', shareAddr: '' } as never,
|
||||
'node.example.test',
|
||||
'fallback.test',
|
||||
)).toBe('[2001:db8::1]');
|
||||
expect(
|
||||
resolveAddr(
|
||||
{
|
||||
...baseInbound,
|
||||
listen: '[2001:db8::1]',
|
||||
shareAddrStrategy: 'listen',
|
||||
shareAddr: '',
|
||||
} as never,
|
||||
'node.example.test',
|
||||
'fallback.test',
|
||||
),
|
||||
).toBe('[2001:db8::1]');
|
||||
});
|
||||
|
||||
it('uses listen strategy to prefer listen and fall back to node override', () => {
|
||||
expect(resolveAddr(
|
||||
{ ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'listen', shareAddr: '' } as never,
|
||||
'node.example.test',
|
||||
'fallback.test',
|
||||
)).toBe('10.0.0.1');
|
||||
expect(resolveAddr(
|
||||
{ ...baseInbound, listen: '0.0.0.0', shareAddrStrategy: 'listen', shareAddr: '' } as never,
|
||||
'node.example.test',
|
||||
'fallback.test',
|
||||
)).toBe('node.example.test');
|
||||
expect(resolveAddr(
|
||||
{ ...baseInbound, listen: 'localhost', shareAddrStrategy: 'listen', shareAddr: '' } as never,
|
||||
'node.example.test',
|
||||
'fallback.test',
|
||||
)).toBe('node.example.test');
|
||||
expect(
|
||||
resolveAddr(
|
||||
{ ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'listen', shareAddr: '' } as never,
|
||||
'node.example.test',
|
||||
'fallback.test',
|
||||
),
|
||||
).toBe('10.0.0.1');
|
||||
expect(
|
||||
resolveAddr(
|
||||
{ ...baseInbound, listen: '0.0.0.0', shareAddrStrategy: 'listen', shareAddr: '' } as never,
|
||||
'node.example.test',
|
||||
'fallback.test',
|
||||
),
|
||||
).toBe('node.example.test');
|
||||
expect(
|
||||
resolveAddr(
|
||||
{
|
||||
...baseInbound,
|
||||
listen: 'localhost',
|
||||
shareAddrStrategy: 'listen',
|
||||
shareAddr: '',
|
||||
} as never,
|
||||
'node.example.test',
|
||||
'fallback.test',
|
||||
),
|
||||
).toBe('node.example.test');
|
||||
});
|
||||
|
||||
it('uses custom strategy address before node override', () => {
|
||||
expect(resolveAddr(
|
||||
{ ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'custom', shareAddr: 'edge.example.test' } as never,
|
||||
'node.example.test',
|
||||
'fallback.test',
|
||||
)).toBe('edge.example.test');
|
||||
expect(
|
||||
resolveAddr(
|
||||
{
|
||||
...baseInbound,
|
||||
listen: '10.0.0.1',
|
||||
shareAddrStrategy: 'custom',
|
||||
shareAddr: 'edge.example.test',
|
||||
} as never,
|
||||
'node.example.test',
|
||||
'fallback.test',
|
||||
),
|
||||
).toBe('edge.example.test');
|
||||
});
|
||||
|
||||
it('normalizes a bare IPv6 custom strategy address', () => {
|
||||
expect(resolveAddr(
|
||||
{ ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'custom', shareAddr: '2001:db8::2' } as never,
|
||||
'node.example.test',
|
||||
'fallback.test',
|
||||
)).toBe('[2001:db8::2]');
|
||||
expect(
|
||||
resolveAddr(
|
||||
{
|
||||
...baseInbound,
|
||||
listen: '10.0.0.1',
|
||||
shareAddrStrategy: 'custom',
|
||||
shareAddr: '2001:db8::2',
|
||||
} as never,
|
||||
'node.example.test',
|
||||
'fallback.test',
|
||||
),
|
||||
).toBe('[2001:db8::2]');
|
||||
});
|
||||
|
||||
it('ignores invalid custom strategy addresses and falls back to node override', () => {
|
||||
for (const shareAddr of ['https://edge.example.test', 'edge.example.test:8443', '[2001:db8::2]:8443', 'bad host']) {
|
||||
expect(resolveAddr(
|
||||
{ ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'custom', shareAddr } as never,
|
||||
'node.example.test',
|
||||
'fallback.test',
|
||||
)).toBe('node.example.test');
|
||||
for (const shareAddr of [
|
||||
'https://edge.example.test',
|
||||
'edge.example.test:8443',
|
||||
'[2001:db8::2]:8443',
|
||||
'bad host',
|
||||
]) {
|
||||
expect(
|
||||
resolveAddr(
|
||||
{ ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'custom', shareAddr } as never,
|
||||
'node.example.test',
|
||||
'fallback.test',
|
||||
),
|
||||
).toBe('node.example.test');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -471,11 +512,9 @@ describe('preferPublicHost (loopback fallback)', () => {
|
||||
|
||||
it('an explicit per-inbound listen still wins over the loopback fallback', () => {
|
||||
const inbound = { listen: '203.0.113.9', port: 443, protocol: 'vless' as const };
|
||||
expect(resolveAddr(
|
||||
inbound as never,
|
||||
'',
|
||||
preferPublicHost('127.0.0.1', 'sub.example.com'),
|
||||
)).toBe('203.0.113.9');
|
||||
expect(
|
||||
resolveAddr(inbound as never, '', preferPublicHost('127.0.0.1', 'sub.example.com')),
|
||||
).toBe('203.0.113.9');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -483,7 +522,10 @@ describe('genInboundLinks orchestrator', () => {
|
||||
// Every full-inbound fixture should produce the same \r\n-joined link
|
||||
// block at this baseline.
|
||||
const fixtures = Object.entries(fullFixtures)
|
||||
.map(([path, raw]): [string, Record<string, unknown>] => [fixtureName(path), raw as Record<string, unknown>])
|
||||
.map(([path, raw]): [string, Record<string, unknown>] => [
|
||||
fixtureName(path),
|
||||
raw as Record<string, unknown>,
|
||||
])
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
for (const [name, raw] of fixtures) {
|
||||
@@ -528,7 +570,8 @@ describe('IPv6 bracket wrapping in share-link authority', () => {
|
||||
it('genVlessLink brackets a bare IPv6 address', () => {
|
||||
const [, raw] = fixturesForProtocol('vless')[0];
|
||||
const typed = InboundSchema.parse(raw);
|
||||
const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id;
|
||||
const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0]
|
||||
.id;
|
||||
|
||||
const link = genVlessLink({
|
||||
inbound: typed,
|
||||
@@ -542,7 +585,8 @@ describe('IPv6 bracket wrapping in share-link authority', () => {
|
||||
it('genTrojanLink brackets a bare IPv6 address', () => {
|
||||
const [, raw] = fixturesForProtocol('trojan')[0];
|
||||
const typed = InboundSchema.parse(raw);
|
||||
const clientPassword = (raw as { settings: { clients: Array<{ password: string }> } }).settings.clients[0].password;
|
||||
const clientPassword = (raw as { settings: { clients: Array<{ password: string }> } }).settings
|
||||
.clients[0].password;
|
||||
|
||||
const link = genTrojanLink({
|
||||
inbound: typed,
|
||||
@@ -556,7 +600,9 @@ describe('IPv6 bracket wrapping in share-link authority', () => {
|
||||
it('genShadowsocksLink brackets a bare IPv6 address', () => {
|
||||
const [, raw] = fixturesForProtocol('shadowsocks')[0];
|
||||
const typed = InboundSchema.parse(raw);
|
||||
const clientPassword = (raw as { settings: { clients?: Array<{ password: string }> } }).settings.clients?.[0]?.password ?? '';
|
||||
const clientPassword =
|
||||
(raw as { settings: { clients?: Array<{ password: string }> } }).settings.clients?.[0]
|
||||
?.password ?? '';
|
||||
|
||||
const link = genShadowsocksLink({
|
||||
inbound: typed,
|
||||
@@ -570,7 +616,8 @@ describe('IPv6 bracket wrapping in share-link authority', () => {
|
||||
it('genHysteriaLink brackets a bare IPv6 address', () => {
|
||||
const [, raw] = fixturesForProtocol('hysteria')[0];
|
||||
const typed = InboundSchema.parse(raw);
|
||||
const clientAuth = (raw as { settings: { clients: Array<{ auth: string }> } }).settings.clients[0].auth;
|
||||
const clientAuth = (raw as { settings: { clients: Array<{ auth: string }> } }).settings
|
||||
.clients[0].auth;
|
||||
|
||||
const link = genHysteriaLink({
|
||||
inbound: typed,
|
||||
@@ -599,7 +646,8 @@ describe('IPv6 bracket wrapping in share-link authority', () => {
|
||||
it('does not bracket IPv4 addresses or hostnames', () => {
|
||||
const [, raw] = fixturesForProtocol('vless')[0];
|
||||
const typed = InboundSchema.parse(raw);
|
||||
const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id;
|
||||
const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0]
|
||||
.id;
|
||||
|
||||
const v4 = genVlessLink({ inbound: typed, address: '203.0.113.7', port: 443, clientId });
|
||||
expect(new URL(v4).host).toBe('203.0.113.7:443');
|
||||
@@ -807,7 +855,10 @@ describe('genVlessLink XHTTP extra compatibility', () => {
|
||||
port: 443,
|
||||
clientId: '11111111-2222-3333-4444-555555555555',
|
||||
});
|
||||
const extra = JSON.parse(new URL(link).searchParams.get('extra') ?? '{}') as Record<string, unknown>;
|
||||
const extra = JSON.parse(new URL(link).searchParams.get('extra') ?? '{}') as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
expect(extra.sessionIDPlacement).toBe('header');
|
||||
expect(extra.sessionIDKey).toBe('X-Session');
|
||||
|
||||
@@ -20,15 +20,47 @@ describe('composeInboundTag transport suffix parity', () => {
|
||||
['vless quic is udp', base({ streamSettings: { network: 'quic' } }), 'in-443-udp'],
|
||||
['vless empty stream defaults tcp', base({}), 'in-443-tcp'],
|
||||
['vmess tcp', base({ protocol: 'vmess', streamSettings: { network: 'tcp' } }), 'in-443-tcp'],
|
||||
['trojan grpc is tcp', base({ protocol: 'trojan', streamSettings: { network: 'grpc' } }), 'in-443-tcp'],
|
||||
['hysteria forced udp', base({ protocol: 'hysteria', streamSettings: { network: 'tcp' } }), 'in-443-udp'],
|
||||
[
|
||||
'trojan grpc is tcp',
|
||||
base({ protocol: 'trojan', streamSettings: { network: 'grpc' } }),
|
||||
'in-443-tcp',
|
||||
],
|
||||
[
|
||||
'hysteria forced udp',
|
||||
base({ protocol: 'hysteria', streamSettings: { network: 'tcp' } }),
|
||||
'in-443-udp',
|
||||
],
|
||||
['wireguard forced udp', base({ protocol: 'wireguard' }), 'in-443-udp'],
|
||||
['shadowsocks tcp,udp', base({ protocol: 'shadowsocks', settings: { network: 'tcp,udp' } }), 'in-443-tcpudp'],
|
||||
['shadowsocks udp only', base({ protocol: 'shadowsocks', settings: { network: 'udp' } }), 'in-443-udp'],
|
||||
['shadowsocks tcp only', base({ protocol: 'shadowsocks', settings: { network: 'tcp' } }), 'in-443-tcp'],
|
||||
['mixed udp on', base({ protocol: 'mixed', streamSettings: { network: 'tcp' }, settings: { udp: true } }), 'in-443-tcpudp'],
|
||||
['mixed udp off', base({ protocol: 'mixed', streamSettings: { network: 'tcp' }, settings: { udp: false } }), 'in-443-tcp'],
|
||||
['tunnel allowedNetwork udp', base({ protocol: 'tunnel', settings: { allowedNetwork: 'udp' } }), 'in-443-udp'],
|
||||
[
|
||||
'shadowsocks tcp,udp',
|
||||
base({ protocol: 'shadowsocks', settings: { network: 'tcp,udp' } }),
|
||||
'in-443-tcpudp',
|
||||
],
|
||||
[
|
||||
'shadowsocks udp only',
|
||||
base({ protocol: 'shadowsocks', settings: { network: 'udp' } }),
|
||||
'in-443-udp',
|
||||
],
|
||||
[
|
||||
'shadowsocks tcp only',
|
||||
base({ protocol: 'shadowsocks', settings: { network: 'tcp' } }),
|
||||
'in-443-tcp',
|
||||
],
|
||||
[
|
||||
'mixed udp on',
|
||||
base({ protocol: 'mixed', streamSettings: { network: 'tcp' }, settings: { udp: true } }),
|
||||
'in-443-tcpudp',
|
||||
],
|
||||
[
|
||||
'mixed udp off',
|
||||
base({ protocol: 'mixed', streamSettings: { network: 'tcp' }, settings: { udp: false } }),
|
||||
'in-443-tcp',
|
||||
],
|
||||
[
|
||||
'tunnel allowedNetwork udp',
|
||||
base({ protocol: 'tunnel', settings: { allowedNetwork: 'udp' } }),
|
||||
'in-443-udp',
|
||||
],
|
||||
];
|
||||
|
||||
it.each(cases)('%s', (_name, input, want) => {
|
||||
@@ -36,17 +68,22 @@ describe('composeInboundTag transport suffix parity', () => {
|
||||
});
|
||||
|
||||
it('ignores the listen address and adds the node prefix', () => {
|
||||
expect(composeInboundTag(base({ port: 8443, streamSettings: { network: 'tcp' } })))
|
||||
.toBe('in-8443-tcp');
|
||||
expect(composeInboundTag(base({ nodeId: 1, port: 443, streamSettings: { network: 'tcp' } })))
|
||||
.toBe('n1-in-443-tcp');
|
||||
expect(composeInboundTag(base({ port: 8443, streamSettings: { network: 'tcp' } }))).toBe(
|
||||
'in-8443-tcp',
|
||||
);
|
||||
expect(
|
||||
composeInboundTag(base({ nodeId: 1, port: 443, streamSettings: { network: 'tcp' } })),
|
||||
).toBe('n1-in-443-tcp');
|
||||
});
|
||||
});
|
||||
|
||||
// Parity with TestIsAutoGeneratedTag.
|
||||
describe('isAutoInboundTag', () => {
|
||||
const input: InboundTagInput = {
|
||||
port: 443, nodeId: null, protocol: 'vless', streamSettings: { network: 'tcp' },
|
||||
port: 443,
|
||||
nodeId: null,
|
||||
protocol: 'vless',
|
||||
streamSettings: { network: 'tcp' },
|
||||
};
|
||||
|
||||
it('recognises canonical, dedup-suffixed and empty as auto', () => {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// The #6121/#6127 guard is hand-written JS walking oxlint's AST, so it can go
|
||||
// silently dead on an oxlint bump. These tests fail loudly when it does.
|
||||
const FIXTURES = 'tools/oxlint/__fixtures__';
|
||||
const RULE = 'input-number(no-synthetic-clear)';
|
||||
|
||||
function runGuard(target: string): string {
|
||||
try {
|
||||
execFileSync('./node_modules/.bin/oxlint', ['-c', `${FIXTURES}/guard.oxlintrc.json`, target], {
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
return '';
|
||||
} catch (error) {
|
||||
return String((error as { stdout?: string }).stdout ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
describe('input-number-guard oxlint plugin', () => {
|
||||
it('rejects every shape that turns a cleared InputNumber into a stored number', () => {
|
||||
const output = runGuard(`${FIXTURES}/synthetic-clear.tsx`);
|
||||
const hits = output.split('\n').filter((line) => line.includes(RULE));
|
||||
|
||||
// Number(v) || N, the typeof ternary, and v ?? N.
|
||||
expect(hits).toHaveLength(3);
|
||||
expect(output).toContain('#6127');
|
||||
});
|
||||
|
||||
it('accepts a handler wrapped with onNumber()', () => {
|
||||
expect(runGuard(`${FIXTURES}/ok.tsx`)).not.toContain(RULE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('input-number-guard wiring', () => {
|
||||
const config = JSON.parse(readFileSync('.oxlintrc.json', 'utf8')) as {
|
||||
jsPlugins: string[];
|
||||
rules: Record<string, unknown>;
|
||||
overrides: { files: string[]; rules: Record<string, unknown> }[];
|
||||
};
|
||||
|
||||
it('loads the plugin and scopes the rule to the pages that regressed', () => {
|
||||
expect(config.jsPlugins).toContain('./tools/oxlint/input-number-guard.mjs');
|
||||
expect(config.rules['input-number/no-synthetic-clear']).toBe('off');
|
||||
|
||||
const enabled = config.overrides.find(
|
||||
(o) => o.rules['input-number/no-synthetic-clear'] === 'error',
|
||||
);
|
||||
expect(enabled?.files).toEqual(['src/pages/settings/**/*.tsx', 'src/pages/xray/**/*.tsx']);
|
||||
|
||||
const exempt = config.overrides.find(
|
||||
(o) => o.rules['input-number/no-synthetic-clear'] === 'off',
|
||||
);
|
||||
expect(exempt?.files).toEqual(['src/pages/xray/**/*Modal.tsx']);
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,9 @@ describe('parseLogLine — SysLog (journalctl) formats', () => {
|
||||
|
||||
describe('parseLogLine — app-log format (SysLog off)', () => {
|
||||
it('parses "YYYY/MM/DD HH:MM:SS LEVEL - body"', () => {
|
||||
const r = parseLogLine('2026/06/09 00:35:09 INFO - mtproto: started mtg for inbound 3 on 0.0.0.0:8443');
|
||||
const r = parseLogLine(
|
||||
'2026/06/09 00:35:09 INFO - mtproto: started mtg for inbound 3 on 0.0.0.0:8443',
|
||||
);
|
||||
expect(r.date).toBe('2026/06/09');
|
||||
expect(r.time).toBe('00:35:09');
|
||||
expect(r.levelText).toBe('INFO');
|
||||
|
||||
@@ -31,11 +31,17 @@ function mtprotoInbound() {
|
||||
|
||||
describe('mtproto multi-client link fan-out', () => {
|
||||
it('emits one tg://proxy per client from settings.clients', () => {
|
||||
const out = genInboundLinks({ inbound: mtprotoInbound(), remark: 'mt-mc', fallbackHostname: 'mt.example.test' });
|
||||
const out = genInboundLinks({
|
||||
inbound: mtprotoInbound(),
|
||||
remark: 'mt-mc',
|
||||
fallbackHostname: 'mt.example.test',
|
||||
});
|
||||
const links = out.split('\r\n').filter(Boolean);
|
||||
expect(links).toHaveLength(2);
|
||||
expect(links[0]).toContain('tg://proxy');
|
||||
expect(links[0]).toContain('secret=ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d');
|
||||
expect(links[0]).toContain(
|
||||
'secret=ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d',
|
||||
);
|
||||
expect(links[1]).toContain('secret=eeabcdefabcdefabcdefabcdefabcdef01676f6f676c652e636f6d');
|
||||
expect(links[0]).not.toContain('#');
|
||||
expect(links[1]).not.toContain('#');
|
||||
|
||||
@@ -7,10 +7,7 @@ import { renderWithProviders } from './test-utils';
|
||||
|
||||
function renderTab(templateSettings: XraySettingsValue) {
|
||||
renderWithProviders(
|
||||
<ObservatorySettingsTab
|
||||
templateSettings={templateSettings}
|
||||
mutate={vi.fn()}
|
||||
/>,
|
||||
<ObservatorySettingsTab templateSettings={templateSettings} mutate={vi.fn()} />,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +21,9 @@ describe('ObservatorySettingsTab', () => {
|
||||
burstObservatory: { subjectSelector: ['proxy-a'] },
|
||||
} as unknown as XraySettingsValue);
|
||||
|
||||
expect(screen.getByText(/This config contains both Observatory and Burst Observatory/)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/This config contains both Observatory and Burst Observatory/),
|
||||
).toBeTruthy();
|
||||
expect(document.querySelector('.ant-segmented')).toBeFalsy();
|
||||
expect(screen.getByText('Probe Destination')).toBeTruthy();
|
||||
expect(screen.queryByText('Probe URL')).toBeFalsy();
|
||||
@@ -39,7 +38,9 @@ describe('ObservatorySettingsTab', () => {
|
||||
burstObservatory: { subjectSelector: ['stale-burst'] },
|
||||
} as unknown as XraySettingsValue);
|
||||
|
||||
expect(screen.getByText(/This config contains both Observatory and Burst Observatory/)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/This config contains both Observatory and Burst Observatory/),
|
||||
).toBeTruthy();
|
||||
expect(document.querySelector('.ant-segmented')).toBeFalsy();
|
||||
expect(screen.getByText('Probe URL')).toBeTruthy();
|
||||
expect(screen.queryByText('Probe Destination')).toBeFalsy();
|
||||
|
||||
@@ -89,9 +89,14 @@ describe('outbound default factories: shape snapshots', () => {
|
||||
|
||||
it('shadowsocks defaults to 2022-blake3-aes-128-gcm', () => {
|
||||
expect(createDefaultShadowsocksOutboundSettings()).toEqual({
|
||||
servers: [{
|
||||
address: '', port: 443, password: '', method: '2022-blake3-aes-128-gcm',
|
||||
}],
|
||||
servers: [
|
||||
{
|
||||
address: '',
|
||||
port: 443,
|
||||
password: '',
|
||||
method: '2022-blake3-aes-128-gcm',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,9 +118,13 @@ describe('outbound default factories: shape snapshots', () => {
|
||||
expect(out.mtu).toBe(1420);
|
||||
expect(out.address).toEqual([]);
|
||||
expect(out.noKernelTun).toBe(false);
|
||||
expect(out.peers).toEqual([{
|
||||
publicKey: '', allowedIPs: ['0.0.0.0/0', '::/0'], endpoint: '',
|
||||
}]);
|
||||
expect(out.peers).toEqual([
|
||||
{
|
||||
publicKey: '',
|
||||
allowedIPs: ['0.0.0.0/0', '::/0'],
|
||||
endpoint: '',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('wireguard generates a secretKey when none is seeded', () => {
|
||||
@@ -126,34 +135,36 @@ describe('outbound default factories: shape snapshots', () => {
|
||||
|
||||
it('hysteria defaults to port 443 version 2', () => {
|
||||
expect(createDefaultHysteriaOutboundSettings()).toEqual({
|
||||
address: '', port: 443, version: 2,
|
||||
address: '',
|
||||
port: 443,
|
||||
version: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('outbound default factories: schema acceptance after stub fill-in', () => {
|
||||
it('freedom default parses (no required fields)', () => {
|
||||
expect(FreedomOutboundSettingsSchema.safeParse(
|
||||
createDefaultFreedomOutboundSettings(),
|
||||
).success).toBe(true);
|
||||
expect(
|
||||
FreedomOutboundSettingsSchema.safeParse(createDefaultFreedomOutboundSettings()).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('blackhole default parses (no required fields)', () => {
|
||||
expect(BlackholeOutboundSettingsSchema.safeParse(
|
||||
createDefaultBlackholeOutboundSettings(),
|
||||
).success).toBe(true);
|
||||
expect(
|
||||
BlackholeOutboundSettingsSchema.safeParse(createDefaultBlackholeOutboundSettings()).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('loopback default parses (no required fields)', () => {
|
||||
expect(LoopbackOutboundSettingsSchema.safeParse(
|
||||
createDefaultLoopbackOutboundSettings(),
|
||||
).success).toBe(true);
|
||||
expect(
|
||||
LoopbackOutboundSettingsSchema.safeParse(createDefaultLoopbackOutboundSettings()).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('dns default parses', () => {
|
||||
expect(DNSOutboundSettingsSchema.safeParse(
|
||||
createDefaultDNSOutboundSettings(),
|
||||
).success).toBe(true);
|
||||
expect(DNSOutboundSettingsSchema.safeParse(createDefaultDNSOutboundSettings()).success).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('vmess parses once vnext fields are filled', () => {
|
||||
@@ -214,8 +225,18 @@ describe('outbound default factories: schema acceptance after stub fill-in', ()
|
||||
|
||||
describe('createDefaultOutboundSettings dispatcher', () => {
|
||||
const PROTOCOLS = [
|
||||
'freedom', 'blackhole', 'dns', 'vmess', 'vless', 'trojan', 'shadowsocks',
|
||||
'socks', 'http', 'wireguard', 'hysteria', 'loopback',
|
||||
'freedom',
|
||||
'blackhole',
|
||||
'dns',
|
||||
'vmess',
|
||||
'vless',
|
||||
'trojan',
|
||||
'shadowsocks',
|
||||
'socks',
|
||||
'http',
|
||||
'wireguard',
|
||||
'hysteria',
|
||||
'loopback',
|
||||
];
|
||||
|
||||
for (const protocol of PROTOCOLS) {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
formValuesToWirePayload,
|
||||
rawOutboundToFormValues,
|
||||
} from '@/lib/xray/outbound-form-adapter';
|
||||
import { formValuesToWirePayload, rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter';
|
||||
|
||||
// Round-trip parity: wire → form → wire should preserve the legacy
|
||||
// Outbound.fromJson(...).toJson() output shape for each protocol's quirks.
|
||||
@@ -17,11 +14,13 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
protocol: 'vmess',
|
||||
tag: 'outbound-vmess',
|
||||
settings: {
|
||||
vnext: [{
|
||||
address: '1.2.3.4',
|
||||
port: 443,
|
||||
users: [{ id: '11111111-2222-4333-8444-555555555555', security: 'auto' }],
|
||||
}],
|
||||
vnext: [
|
||||
{
|
||||
address: '1.2.3.4',
|
||||
port: 443,
|
||||
users: [{ id: '11111111-2222-4333-8444-555555555555', security: 'auto' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const form = rawOutboundToFormValues(wire);
|
||||
@@ -37,11 +36,13 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
protocol: 'vmess',
|
||||
tag: 'outbound-vmess',
|
||||
settings: {
|
||||
vnext: [{
|
||||
address: '1.2.3.4',
|
||||
port: 443,
|
||||
users: [{ id: '11111111-2222-4333-8444-555555555555', security: 'auto' }],
|
||||
}],
|
||||
vnext: [
|
||||
{
|
||||
address: '1.2.3.4',
|
||||
port: 443,
|
||||
users: [{ id: '11111111-2222-4333-8444-555555555555', security: 'auto' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -90,7 +91,9 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
if (form.protocol === 'vless') {
|
||||
expect(form.settings.encryption).toBe(enc);
|
||||
}
|
||||
expect((formValuesToWirePayload(form).settings as Record<string, unknown>).encryption).toBe(enc);
|
||||
expect((formValuesToWirePayload(form).settings as Record<string, unknown>).encryption).toBe(
|
||||
enc,
|
||||
);
|
||||
});
|
||||
|
||||
it('vless emits reverse + sniffing when reverseTag is set', () => {
|
||||
@@ -120,8 +123,13 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
const wire = {
|
||||
protocol: 'vless',
|
||||
settings: {
|
||||
address: 'srv', port: 443, id: '11111111-2222-4333-8444-555555555555',
|
||||
flow: '', encryption: 'none', testpre: 5, testseed: [1, 2, 3, 4],
|
||||
address: 'srv',
|
||||
port: 443,
|
||||
id: '11111111-2222-4333-8444-555555555555',
|
||||
flow: '',
|
||||
encryption: 'none',
|
||||
testpre: 5,
|
||||
testseed: [1, 2, 3, 4],
|
||||
},
|
||||
};
|
||||
const back = formValuesToWirePayload(rawOutboundToFormValues(wire));
|
||||
@@ -147,10 +155,16 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
const wire = {
|
||||
protocol: 'shadowsocks',
|
||||
settings: {
|
||||
servers: [{
|
||||
address: 's', port: 443, password: 'pw',
|
||||
method: '2022-blake3-aes-128-gcm', uot: true, UoTVersion: 2,
|
||||
}],
|
||||
servers: [
|
||||
{
|
||||
address: 's',
|
||||
port: 443,
|
||||
password: 'pw',
|
||||
method: '2022-blake3-aes-128-gcm',
|
||||
uot: true,
|
||||
UoTVersion: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const back = formValuesToWirePayload(rawOutboundToFormValues(wire));
|
||||
@@ -160,16 +174,20 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
});
|
||||
|
||||
it('socks emits users:[] when user is empty, users:[{...}] when set', () => {
|
||||
const noUser = formValuesToWirePayload(rawOutboundToFormValues({
|
||||
protocol: 'socks',
|
||||
settings: { servers: [{ address: 's', port: 1080 }] },
|
||||
}));
|
||||
const noUser = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'socks',
|
||||
settings: { servers: [{ address: 's', port: 1080 }] },
|
||||
}),
|
||||
);
|
||||
expect(noUser.settings).toMatchObject({ servers: [{ users: [] }] });
|
||||
|
||||
const withUser = formValuesToWirePayload(rawOutboundToFormValues({
|
||||
protocol: 'socks',
|
||||
settings: { servers: [{ address: 's', port: 1080, users: [{ user: 'u', pass: 'p' }] }] },
|
||||
}));
|
||||
const withUser = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'socks',
|
||||
settings: { servers: [{ address: 's', port: 1080, users: [{ user: 'u', pass: 'p' }] }] },
|
||||
}),
|
||||
);
|
||||
expect(withUser.settings).toMatchObject({
|
||||
servers: [{ users: [{ user: 'u', pass: 'p' }] }],
|
||||
});
|
||||
@@ -191,10 +209,12 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
});
|
||||
|
||||
it('http omits headers when empty', () => {
|
||||
const back = formValuesToWirePayload(rawOutboundToFormValues({
|
||||
protocol: 'http',
|
||||
settings: { servers: [{ address: 'a', port: 8080, users: [] }] },
|
||||
}));
|
||||
const back = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'http',
|
||||
settings: { servers: [{ address: 'a', port: 8080, users: [] }] },
|
||||
}),
|
||||
);
|
||||
expect(back.settings).not.toHaveProperty('headers');
|
||||
});
|
||||
|
||||
@@ -205,7 +225,9 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
mtu: 1420,
|
||||
secretKey: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
||||
address: ['10.0.0.1', 'fd00::1'],
|
||||
peers: [{ publicKey: 'pk', allowedIPs: ['0.0.0.0/0'], endpoint: 'e:51820', preSharedKey: 'psk' }],
|
||||
peers: [
|
||||
{ publicKey: 'pk', allowedIPs: ['0.0.0.0/0'], endpoint: 'e:51820', preSharedKey: 'psk' },
|
||||
],
|
||||
reserved: [1, 2, 3],
|
||||
noKernelTun: false,
|
||||
},
|
||||
@@ -225,16 +247,20 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
});
|
||||
|
||||
it('blackhole wraps type into {response:{type}} and omits when empty', () => {
|
||||
const empty = formValuesToWirePayload(rawOutboundToFormValues({
|
||||
protocol: 'blackhole',
|
||||
settings: {},
|
||||
}));
|
||||
const empty = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'blackhole',
|
||||
settings: {},
|
||||
}),
|
||||
);
|
||||
expect(empty.settings).toEqual({ response: undefined });
|
||||
|
||||
const withType = formValuesToWirePayload(rawOutboundToFormValues({
|
||||
protocol: 'blackhole',
|
||||
settings: { response: { type: 'http' } },
|
||||
}));
|
||||
const withType = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'blackhole',
|
||||
settings: { response: { type: 'http' } },
|
||||
}),
|
||||
);
|
||||
expect(withType.settings).toEqual({ response: { type: 'http' } });
|
||||
});
|
||||
|
||||
@@ -254,7 +280,11 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
const back = formValuesToWirePayload(rawOutboundToFormValues(wire));
|
||||
const settings = back.settings as Record<string, unknown>;
|
||||
const rules = settings.rules as Array<Record<string, unknown>>;
|
||||
expect(rules[0]).toEqual({ action: 'direct', qType: 'A,AAAA', domain: ['example.com', 'ext.org'] });
|
||||
expect(rules[0]).toEqual({
|
||||
action: 'direct',
|
||||
qType: 'A,AAAA',
|
||||
domain: ['example.com', 'ext.org'],
|
||||
});
|
||||
expect(rules[1]).toEqual({ action: 'return', qType: 28, domain: ['blocked.com'], rCode: 3 });
|
||||
});
|
||||
|
||||
@@ -264,15 +294,19 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
settings: { rules: [{ action: 'direct', qtype: 'TXT' }] },
|
||||
};
|
||||
const back = formValuesToWirePayload(rawOutboundToFormValues(wire));
|
||||
const rules = (back.settings as Record<string, unknown>).rules as Array<Record<string, unknown>>;
|
||||
const rules = (back.settings as Record<string, unknown>).rules as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(rules[0]).toEqual({ action: 'direct', qType: 'TXT' });
|
||||
});
|
||||
|
||||
it('freedom emits domainStrategy/redirect/fragment conditionally', () => {
|
||||
const empty = formValuesToWirePayload(rawOutboundToFormValues({
|
||||
protocol: 'freedom',
|
||||
settings: {},
|
||||
}));
|
||||
const empty = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'freedom',
|
||||
settings: {},
|
||||
}),
|
||||
);
|
||||
expect(empty.settings).toEqual({
|
||||
domainStrategy: undefined,
|
||||
redirect: undefined,
|
||||
@@ -281,17 +315,19 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
finalRules: undefined,
|
||||
});
|
||||
|
||||
const filled = formValuesToWirePayload(rawOutboundToFormValues({
|
||||
protocol: 'freedom',
|
||||
settings: {
|
||||
domainStrategy: 'UseIPv4',
|
||||
redirect: '1.1.1.1',
|
||||
userLevel: 3,
|
||||
proxyProtocol: 2,
|
||||
fragment: { packets: 'tlshello', length: '100-200' },
|
||||
noises: [{ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ipv4' }],
|
||||
},
|
||||
}));
|
||||
const filled = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'freedom',
|
||||
settings: {
|
||||
domainStrategy: 'UseIPv4',
|
||||
redirect: '1.1.1.1',
|
||||
userLevel: 3,
|
||||
proxyProtocol: 2,
|
||||
fragment: { packets: 'tlshello', length: '100-200' },
|
||||
noises: [{ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ipv4' }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(filled.settings).toMatchObject({
|
||||
domainStrategy: 'UseIPv4',
|
||||
redirect: '1.1.1.1',
|
||||
@@ -324,60 +360,92 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
});
|
||||
|
||||
it('freedom omits proxyProtocol when disabled (0)', () => {
|
||||
const round = formValuesToWirePayload(rawOutboundToFormValues({
|
||||
protocol: 'freedom',
|
||||
settings: { proxyProtocol: 0 },
|
||||
}));
|
||||
const round = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'freedom',
|
||||
settings: { proxyProtocol: 0 },
|
||||
}),
|
||||
);
|
||||
expect((round.settings as { proxyProtocol?: number }).proxyProtocol).toBeUndefined();
|
||||
});
|
||||
|
||||
it('mux is only emitted when enabled AND protocol/network/flow allow it', () => {
|
||||
// Disabled mux: omitted
|
||||
const disabled = formValuesToWirePayload(rawOutboundToFormValues({
|
||||
protocol: 'vless',
|
||||
settings: { address: 's', port: 443, id: '11111111-2222-4333-8444-555555555555', flow: '', encryption: 'none' },
|
||||
mux: { enabled: false },
|
||||
}));
|
||||
const disabled = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'vless',
|
||||
settings: {
|
||||
address: 's',
|
||||
port: 443,
|
||||
id: '11111111-2222-4333-8444-555555555555',
|
||||
flow: '',
|
||||
encryption: 'none',
|
||||
},
|
||||
mux: { enabled: false },
|
||||
}),
|
||||
);
|
||||
expect(disabled).not.toHaveProperty('mux');
|
||||
|
||||
// Enabled mux on vless without flow: emitted
|
||||
const enabled = formValuesToWirePayload(rawOutboundToFormValues({
|
||||
protocol: 'vless',
|
||||
settings: { address: 's', port: 443, id: '11111111-2222-4333-8444-555555555555', flow: '', encryption: 'none' },
|
||||
mux: { enabled: true, concurrency: 8, xudpConcurrency: 16, xudpProxyUDP443: 'reject' },
|
||||
}));
|
||||
const enabled = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'vless',
|
||||
settings: {
|
||||
address: 's',
|
||||
port: 443,
|
||||
id: '11111111-2222-4333-8444-555555555555',
|
||||
flow: '',
|
||||
encryption: 'none',
|
||||
},
|
||||
mux: { enabled: true, concurrency: 8, xudpConcurrency: 16, xudpProxyUDP443: 'reject' },
|
||||
}),
|
||||
);
|
||||
expect(enabled.mux).toMatchObject({ enabled: true });
|
||||
|
||||
// Enabled mux on vless with vision flow: gated out
|
||||
const withFlow = formValuesToWirePayload(rawOutboundToFormValues({
|
||||
protocol: 'vless',
|
||||
settings: { address: 's', port: 443, id: '11111111-2222-4333-8444-555555555555', flow: 'xtls-rprx-vision', encryption: 'none' },
|
||||
mux: { enabled: true },
|
||||
}));
|
||||
const withFlow = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'vless',
|
||||
settings: {
|
||||
address: 's',
|
||||
port: 443,
|
||||
id: '11111111-2222-4333-8444-555555555555',
|
||||
flow: 'xtls-rprx-vision',
|
||||
encryption: 'none',
|
||||
},
|
||||
mux: { enabled: true },
|
||||
}),
|
||||
);
|
||||
expect(withFlow).not.toHaveProperty('mux');
|
||||
|
||||
// Freedom (non-mux protocol): gated out even if enabled
|
||||
const freedom = formValuesToWirePayload(rawOutboundToFormValues({
|
||||
protocol: 'freedom',
|
||||
settings: {},
|
||||
mux: { enabled: true },
|
||||
}));
|
||||
const freedom = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'freedom',
|
||||
settings: {},
|
||||
mux: { enabled: true },
|
||||
}),
|
||||
);
|
||||
expect(freedom).not.toHaveProperty('mux');
|
||||
});
|
||||
|
||||
it('hysteria preserves address/port/version literal 2', () => {
|
||||
const back = formValuesToWirePayload(rawOutboundToFormValues({
|
||||
protocol: 'hysteria',
|
||||
settings: { address: 'h.example', port: 8443, version: 2 },
|
||||
}));
|
||||
const back = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'hysteria',
|
||||
settings: { address: 'h.example', port: 8443, version: 2 },
|
||||
}),
|
||||
);
|
||||
expect(back.settings).toEqual({ address: 'h.example', port: 8443, version: 2 });
|
||||
});
|
||||
|
||||
it('loopback inboundTag round-trips', () => {
|
||||
const back = formValuesToWirePayload(rawOutboundToFormValues({
|
||||
protocol: 'loopback',
|
||||
settings: { inboundTag: 'tagged-inbound' },
|
||||
}));
|
||||
const back = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'loopback',
|
||||
settings: { inboundTag: 'tagged-inbound' },
|
||||
}),
|
||||
);
|
||||
expect(back.settings).toEqual({ inboundTag: 'tagged-inbound' });
|
||||
});
|
||||
|
||||
@@ -422,11 +490,19 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
|
||||
describe('outbound-form-adapter: targetStrategy', () => {
|
||||
it('round-trips a top-level targetStrategy', () => {
|
||||
const back = formValuesToWirePayload(rawOutboundToFormValues({
|
||||
protocol: 'vless',
|
||||
settings: { address: 's', port: 443, id: '11111111-2222-4333-8444-555555555555', flow: '', encryption: 'none' },
|
||||
targetStrategy: 'ForceIPv6v4',
|
||||
}));
|
||||
const back = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'vless',
|
||||
settings: {
|
||||
address: 's',
|
||||
port: 443,
|
||||
id: '11111111-2222-4333-8444-555555555555',
|
||||
flow: '',
|
||||
encryption: 'none',
|
||||
},
|
||||
targetStrategy: 'ForceIPv6v4',
|
||||
}),
|
||||
);
|
||||
expect(back.targetStrategy).toBe('ForceIPv6v4');
|
||||
});
|
||||
|
||||
@@ -440,17 +516,21 @@ describe('outbound-form-adapter: targetStrategy', () => {
|
||||
});
|
||||
|
||||
it('omits targetStrategy when unset and drops unknown values', () => {
|
||||
const unset = formValuesToWirePayload(rawOutboundToFormValues({
|
||||
protocol: 'freedom',
|
||||
settings: {},
|
||||
}));
|
||||
const unset = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'freedom',
|
||||
settings: {},
|
||||
}),
|
||||
);
|
||||
expect(unset).not.toHaveProperty('targetStrategy');
|
||||
|
||||
const invalid = formValuesToWirePayload(rawOutboundToFormValues({
|
||||
protocol: 'freedom',
|
||||
settings: {},
|
||||
targetStrategy: 'UseIPv5',
|
||||
}));
|
||||
const invalid = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'freedom',
|
||||
settings: {},
|
||||
targetStrategy: 'UseIPv5',
|
||||
}),
|
||||
);
|
||||
expect(invalid).not.toHaveProperty('targetStrategy');
|
||||
});
|
||||
|
||||
@@ -473,16 +553,27 @@ describe('outbound-form-adapter: xhttp xmux toggle', () => {
|
||||
protocol: 'vless',
|
||||
tag: 'out-xhttp',
|
||||
settings: {
|
||||
address: 's', port: 443, id: '11111111-2222-4333-8444-555555555555',
|
||||
flow: '', encryption: 'none',
|
||||
address: 's',
|
||||
port: 443,
|
||||
id: '11111111-2222-4333-8444-555555555555',
|
||||
flow: '',
|
||||
encryption: 'none',
|
||||
},
|
||||
streamSettings: {
|
||||
network: 'xhttp',
|
||||
security: 'none',
|
||||
xhttpSettings: {
|
||||
path: '/', host: '', mode: '',
|
||||
xPaddingBytes: '100-1000', scMaxEachPostBytes: '1000000',
|
||||
xmux: { maxConcurrency: '11', maxConnections: '1', hMaxRequestTimes: '1', hMaxReusableSecs: '1' },
|
||||
path: '/',
|
||||
host: '',
|
||||
mode: '',
|
||||
xPaddingBytes: '100-1000',
|
||||
scMaxEachPostBytes: '1000000',
|
||||
xmux: {
|
||||
maxConcurrency: '11',
|
||||
maxConnections: '1',
|
||||
hMaxRequestTimes: '1',
|
||||
hMaxReusableSecs: '1',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -504,21 +595,34 @@ describe('outbound-form-adapter: xhttp xmux toggle', () => {
|
||||
|
||||
it('round-trips xmux on save, strips enableXmux, and enforces xmux exclusivity', () => {
|
||||
const back = formValuesToWirePayload(rawOutboundToFormValues(xmuxWire));
|
||||
const xhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
|
||||
const xhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(xhttp).not.toHaveProperty('enableXmux');
|
||||
const xmux = xhttp.xmux as Record<string, unknown>;
|
||||
// xray-core rejects maxConnections + maxConcurrency together; the
|
||||
// explicit maxConnections wins and maxConcurrency is dropped.
|
||||
expect(xmux).not.toHaveProperty('maxConcurrency');
|
||||
expect(xmux).toMatchObject({ maxConnections: '1', hMaxRequestTimes: '1', hMaxReusableSecs: '1' });
|
||||
expect(xmux).toMatchObject({
|
||||
maxConnections: '1',
|
||||
hMaxRequestTimes: '1',
|
||||
hMaxReusableSecs: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops xmux on save when the toggle is off', () => {
|
||||
const form = rawOutboundToFormValues(xmuxWire);
|
||||
const xhttp = (form.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
|
||||
const xhttp = (form.streamSettings as Record<string, unknown>).xhttpSettings as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
xhttp.enableXmux = false;
|
||||
const back = formValuesToWirePayload(form);
|
||||
const wireXhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
|
||||
const wireXhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(wireXhttp).not.toHaveProperty('xmux');
|
||||
});
|
||||
|
||||
@@ -534,13 +638,19 @@ describe('outbound-form-adapter: xhttp xmux toggle', () => {
|
||||
},
|
||||
};
|
||||
const form = rawOutboundToFormValues(wire);
|
||||
const xhttp = (form.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
|
||||
const xhttp = (form.streamSettings as Record<string, unknown>).xhttpSettings as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const xmux = xhttp.xmux as Record<string, unknown>;
|
||||
expect(xmux.maxConcurrency).toBe('1-2');
|
||||
expect(xmux.maxConnections).toBe(0);
|
||||
|
||||
const back = formValuesToWirePayload(form);
|
||||
const wireXhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
|
||||
const wireXhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const wireXmux = wireXhttp.xmux as Record<string, unknown>;
|
||||
expect(wireXmux.maxConcurrency).toBe('1-2');
|
||||
});
|
||||
@@ -550,7 +660,11 @@ describe('outbound-form-adapter: full optional-block round-trip', () => {
|
||||
const wire = {
|
||||
protocol: 'vless',
|
||||
settings: {
|
||||
address: '1', port: 443, id: '1', flow: '', encryption: 'none',
|
||||
address: '1',
|
||||
port: 443,
|
||||
id: '1',
|
||||
flow: '',
|
||||
encryption: 'none',
|
||||
reverse: {
|
||||
tag: '1',
|
||||
sniffing: {
|
||||
@@ -566,10 +680,26 @@ describe('outbound-form-adapter: full optional-block round-trip', () => {
|
||||
tag: '1',
|
||||
streamSettings: {
|
||||
network: 'tcp',
|
||||
tcpSettings: { header: { type: 'http', request: { version: '1.1', method: 'GET', path: ['/'], headers: { '1': ['1'] } }, response: { version: '1.1', status: '200', reason: 'OK', headers: { '1': ['1'] } } } },
|
||||
tcpSettings: {
|
||||
header: {
|
||||
type: 'http',
|
||||
request: { version: '1.1', method: 'GET', path: ['/'], headers: { '1': ['1'] } },
|
||||
response: { version: '1.1', status: '200', reason: 'OK', headers: { '1': ['1'] } },
|
||||
},
|
||||
},
|
||||
security: 'none',
|
||||
sockopt: { tcpFastOpen: true, customSockopt: [{ type: 'int', level: '6', opt: '1', value: '1' }] },
|
||||
finalmask: { tcp: [{ type: 'fragment', settings: { packets: '1-3', length: '1', delay: '1', maxSplit: '1' } }] },
|
||||
sockopt: {
|
||||
tcpFastOpen: true,
|
||||
customSockopt: [{ type: 'int', level: '6', opt: '1', value: '1' }],
|
||||
},
|
||||
finalmask: {
|
||||
tcp: [
|
||||
{
|
||||
type: 'fragment',
|
||||
settings: { packets: '1-3', length: '1', delay: '1', maxSplit: '1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
sendThrough: '1',
|
||||
mux: { enabled: true, concurrency: 8, xudpConcurrency: 16, xudpProxyUDP443: 'reject' },
|
||||
@@ -578,7 +708,10 @@ describe('outbound-form-adapter: full optional-block round-trip', () => {
|
||||
it('preserves sockopt, finalmask, mux, and reverse excludes', () => {
|
||||
const back = formValuesToWirePayload(rawOutboundToFormValues(wire));
|
||||
const settings = back.settings as Record<string, unknown>;
|
||||
const sniffing = (settings.reverse as Record<string, unknown>).sniffing as Record<string, unknown>;
|
||||
const sniffing = (settings.reverse as Record<string, unknown>).sniffing as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(sniffing.ipsExcluded).toEqual(['1']);
|
||||
expect(sniffing.domainsExcluded).toEqual(['1']);
|
||||
|
||||
|
||||
@@ -38,7 +38,9 @@ describe('OutboundFormModal', () => {
|
||||
chooseSelectOption('protocol', proto);
|
||||
// Flush antd Form.useWatch('protocol') so protocol-specific fields render before
|
||||
// reading; otherwise every iteration sees the same default (vless) DOM.
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
labelsByProto[proto] = fieldLabels();
|
||||
}
|
||||
|
||||
@@ -64,11 +66,13 @@ describe('OutboundFormModal', () => {
|
||||
protocol: 'vless',
|
||||
tag: 'reverse-out',
|
||||
settings: {
|
||||
vnext: [{
|
||||
address: 'example.com',
|
||||
port: 443,
|
||||
users: [{ id: 'c9f0c2d0-0000-4000-8000-000000000000', encryption: 'none' }],
|
||||
}],
|
||||
vnext: [
|
||||
{
|
||||
address: 'example.com',
|
||||
port: 443,
|
||||
users: [{ id: 'c9f0c2d0-0000-4000-8000-000000000000', encryption: 'none' }],
|
||||
},
|
||||
],
|
||||
reverse: { tag: 'r1', sniffing: {} },
|
||||
},
|
||||
streamSettings: { network: 'tcp', security: 'none' },
|
||||
@@ -78,12 +82,18 @@ describe('OutboundFormModal', () => {
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement;
|
||||
expect(ok).toBeTruthy();
|
||||
await act(async () => { fireEvent.click(ok); });
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
|
||||
await act(async () => {
|
||||
fireEvent.click(ok);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
const payload = onConfirm.mock.calls[0][0] as {
|
||||
|
||||
@@ -19,17 +19,33 @@ import { Base64 } from '@/utils';
|
||||
describe('parseVmessLink', () => {
|
||||
it('parses a vmess:// link with ws + tls', () => {
|
||||
const json = {
|
||||
v: '2', ps: 'imported-vmess', add: '1.2.3.4', port: 8443,
|
||||
id: '11111111-2222-4333-8444-555555555555', aid: 0, scy: 'auto',
|
||||
net: 'ws', host: 'example.com', path: '/ws',
|
||||
tls: 'tls', sni: 'example.com', fp: 'chrome', alpn: 'h2,http/1.1',
|
||||
v: '2',
|
||||
ps: 'imported-vmess',
|
||||
add: '1.2.3.4',
|
||||
port: 8443,
|
||||
id: '11111111-2222-4333-8444-555555555555',
|
||||
aid: 0,
|
||||
scy: 'auto',
|
||||
net: 'ws',
|
||||
host: 'example.com',
|
||||
path: '/ws',
|
||||
tls: 'tls',
|
||||
sni: 'example.com',
|
||||
fp: 'chrome',
|
||||
alpn: 'h2,http/1.1',
|
||||
};
|
||||
const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
|
||||
const out = parseVmessLink(link);
|
||||
expect(out).not.toBeNull();
|
||||
expect(out?.protocol).toBe('vmess');
|
||||
expect(out?.tag).toBe('imported-vmess');
|
||||
const settings = out?.settings as { vnext: Array<{ address: string; port: number; users: Array<{ id: string; security: string }> }> };
|
||||
const settings = out?.settings as {
|
||||
vnext: Array<{
|
||||
address: string;
|
||||
port: number;
|
||||
users: Array<{ id: string; security: string }>;
|
||||
}>;
|
||||
};
|
||||
expect(settings.vnext[0].address).toBe('1.2.3.4');
|
||||
expect(settings.vnext[0].port).toBe(8443);
|
||||
expect(settings.vnext[0].users[0].id).toBe('11111111-2222-4333-8444-555555555555');
|
||||
@@ -53,15 +69,24 @@ describe('parseVmessLink', () => {
|
||||
describe('parseVmessLink — XHTTP advanced fields', () => {
|
||||
it('round-trips xhttp knobs from the vmess JSON', () => {
|
||||
const json = {
|
||||
v: '2', ps: 'imported-xhttp', add: '1.2.3.4', port: 443,
|
||||
id: '11111111-2222-4333-8444-555555555555', aid: 0, scy: 'auto',
|
||||
net: 'xhttp', host: 'edge.example', path: '/sp', mode: 'stream-up',
|
||||
v: '2',
|
||||
ps: 'imported-xhttp',
|
||||
add: '1.2.3.4',
|
||||
port: 443,
|
||||
id: '11111111-2222-4333-8444-555555555555',
|
||||
aid: 0,
|
||||
scy: 'auto',
|
||||
net: 'xhttp',
|
||||
host: 'edge.example',
|
||||
path: '/sp',
|
||||
mode: 'stream-up',
|
||||
xPaddingBytes: '500-1500',
|
||||
scMaxEachPostBytes: '2000000',
|
||||
scMinPostsIntervalMs: '60',
|
||||
uplinkChunkSize: 8192,
|
||||
noGRPCHeader: true,
|
||||
tls: 'tls', sni: 'edge.example',
|
||||
tls: 'tls',
|
||||
sni: 'edge.example',
|
||||
};
|
||||
const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
|
||||
const out = parseVmessLink(link);
|
||||
@@ -79,9 +104,16 @@ describe('parseVmessLink — XHTTP advanced fields', () => {
|
||||
|
||||
it('round-trips xhttp padding-obfs knobs from the vmess JSON', () => {
|
||||
const json = {
|
||||
v: '2', ps: 'imported-pad', add: '1.2.3.4', port: 443,
|
||||
id: '11111111-2222-4333-8444-555555555555', aid: 0, scy: 'auto',
|
||||
net: 'xhttp', host: 'edge.example', path: '/sp',
|
||||
v: '2',
|
||||
ps: 'imported-pad',
|
||||
add: '1.2.3.4',
|
||||
port: 443,
|
||||
id: '11111111-2222-4333-8444-555555555555',
|
||||
aid: 0,
|
||||
scy: 'auto',
|
||||
net: 'xhttp',
|
||||
host: 'edge.example',
|
||||
path: '/sp',
|
||||
xPaddingObfsMode: true,
|
||||
xPaddingKey: 'secret-key',
|
||||
xPaddingHeader: 'X-Pad',
|
||||
@@ -96,7 +128,10 @@ describe('parseVmessLink — XHTTP advanced fields', () => {
|
||||
// legacy sessionKey must alias onto the renamed sessionIDKey (#6258)
|
||||
const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
|
||||
const out = parseVmessLink(link);
|
||||
const xhttp = (out?.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
|
||||
const xhttp = (out!.streamSettings as Record<string, unknown>).xhttpSettings as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(xhttp.xPaddingObfsMode).toBe(true);
|
||||
expect(xhttp.xPaddingKey).toBe('secret-key');
|
||||
expect(xhttp.xPaddingHeader).toBe('X-Pad');
|
||||
@@ -112,12 +147,12 @@ describe('parseVmessLink — XHTTP advanced fields', () => {
|
||||
|
||||
describe('parseVlessLink — XHTTP advanced fields', () => {
|
||||
it('round-trips xhttp knobs from URL query params', () => {
|
||||
const link
|
||||
= 'vless://uuid@srv.example:443'
|
||||
+ '?type=xhttp&security=tls&host=edge.example&path=%2Fsp&mode=stream-up'
|
||||
+ '&xPaddingBytes=500-1500&scMaxEachPostBytes=2000000'
|
||||
+ '&scMinPostsIntervalMs=60&uplinkChunkSize=8192&noGRPCHeader=true'
|
||||
+ '#imported-xhttp';
|
||||
const link =
|
||||
'vless://uuid@srv.example:443' +
|
||||
'?type=xhttp&security=tls&host=edge.example&path=%2Fsp&mode=stream-up' +
|
||||
'&xPaddingBytes=500-1500&scMaxEachPostBytes=2000000' +
|
||||
'&scMinPostsIntervalMs=60&uplinkChunkSize=8192&noGRPCHeader=true' +
|
||||
'#imported-xhttp';
|
||||
const out = parseVlessLink(link);
|
||||
const stream = out?.streamSettings as Record<string, unknown>;
|
||||
const xhttp = stream.xhttpSettings as Record<string, unknown>;
|
||||
@@ -132,17 +167,20 @@ describe('parseVlessLink — XHTTP advanced fields', () => {
|
||||
});
|
||||
|
||||
it('round-trips xhttp padding-obfs knobs from URL query params', () => {
|
||||
const link
|
||||
= 'vless://uuid@srv.example:443'
|
||||
+ '?type=xhttp&security=tls&host=edge.example&path=%2Fsp'
|
||||
+ '&xPaddingObfsMode=true&xPaddingKey=secret-key&xPaddingHeader=X-Pad'
|
||||
+ '&xPaddingPlacement=header&xPaddingMethod=random'
|
||||
+ '&sessionIDKey=X-Session&sessionIDTable=Base62&sessionIDLength=16-32'
|
||||
+ '&seqKey=X-Seq&noSSEHeader=true'
|
||||
+ '&scMaxBufferedPosts=50'
|
||||
+ '#imported-pad';
|
||||
const link =
|
||||
'vless://uuid@srv.example:443' +
|
||||
'?type=xhttp&security=tls&host=edge.example&path=%2Fsp' +
|
||||
'&xPaddingObfsMode=true&xPaddingKey=secret-key&xPaddingHeader=X-Pad' +
|
||||
'&xPaddingPlacement=header&xPaddingMethod=random' +
|
||||
'&sessionIDKey=X-Session&sessionIDTable=Base62&sessionIDLength=16-32' +
|
||||
'&seqKey=X-Seq&noSSEHeader=true' +
|
||||
'&scMaxBufferedPosts=50' +
|
||||
'#imported-pad';
|
||||
const out = parseVlessLink(link);
|
||||
const xhttp = (out?.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
|
||||
const xhttp = (out!.streamSettings as Record<string, unknown>).xhttpSettings as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(xhttp.xPaddingObfsMode).toBe(true);
|
||||
expect(xhttp.xPaddingKey).toBe('secret-key');
|
||||
expect(xhttp.xPaddingHeader).toBe('X-Pad');
|
||||
@@ -159,10 +197,10 @@ describe('parseVlessLink — XHTTP advanced fields', () => {
|
||||
|
||||
describe('parseVlessLink', () => {
|
||||
it('parses a vless:// link with reality', () => {
|
||||
const link
|
||||
= 'vless://11111111-2222-4333-8444-555555555555@srv.example:443'
|
||||
+ '?type=tcp&security=reality&pbk=pubkey&sid=abcd&fp=chrome&sni=cloudflare.com&flow=xtls-rprx-vision'
|
||||
+ '#imported-vless';
|
||||
const link =
|
||||
'vless://11111111-2222-4333-8444-555555555555@srv.example:443' +
|
||||
'?type=tcp&security=reality&pbk=pubkey&sid=abcd&fp=chrome&sni=cloudflare.com&flow=xtls-rprx-vision' +
|
||||
'#imported-vless';
|
||||
const out = parseVlessLink(link);
|
||||
expect(out?.protocol).toBe('vless');
|
||||
expect(out?.tag).toBe('imported-vless');
|
||||
@@ -182,16 +220,19 @@ describe('parseVlessLink', () => {
|
||||
it('parses encryption + pqv (post-quantum) into settings and mldsa65Verify', () => {
|
||||
const enc = 'mlkem768x25519plus.native.0rtt.G3cdPSd1-NnlpTbWNSM5vHsT5VNzWfFzYSKwbUMnV1Y';
|
||||
const pqv = 'GIsemxbGPjDRH1ONfmoGlVkJ4etNuLmYDvzpjmFFreDLd8WjoJxJ4Fmt_NQJaC6';
|
||||
const link
|
||||
= 'vless://9406c224-8ac6-4675-ae0b-f93785959418@localhost:1121'
|
||||
+ `?encryption=${enc}&pqv=${pqv}`
|
||||
+ '&security=reality&sid=29cf418813d5bac7&sni=aws.amazon.com'
|
||||
+ '&pbk=aQaGBOT2hMfXWebYtjADoOVUrP8qZRdwXVap7nrId0I&fp=chrome&spx=%2FOUTjB7xHRiP4zBP&type=tcp'
|
||||
+ '#giqssbgmo9';
|
||||
const link =
|
||||
'vless://9406c224-8ac6-4675-ae0b-f93785959418@localhost:1121' +
|
||||
`?encryption=${enc}&pqv=${pqv}` +
|
||||
'&security=reality&sid=29cf418813d5bac7&sni=aws.amazon.com' +
|
||||
'&pbk=aQaGBOT2hMfXWebYtjADoOVUrP8qZRdwXVap7nrId0I&fp=chrome&spx=%2FOUTjB7xHRiP4zBP&type=tcp' +
|
||||
'#giqssbgmo9';
|
||||
const out = parseVlessLink(link);
|
||||
const settings = out?.settings as { encryption: string };
|
||||
expect(settings.encryption).toBe(enc);
|
||||
const reality = (out?.streamSettings as Record<string, unknown>).realitySettings as Record<string, unknown>;
|
||||
const reality = (out!.streamSettings as Record<string, unknown>).realitySettings as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(reality.mldsa65Verify).toBe(pqv);
|
||||
expect(reality.publicKey).toBe('aQaGBOT2hMfXWebYtjADoOVUrP8qZRdwXVap7nrId0I');
|
||||
});
|
||||
@@ -199,10 +240,13 @@ describe('parseVlessLink', () => {
|
||||
|
||||
describe('parseTrojanLink', () => {
|
||||
it('parses a trojan:// link with ws + tls', () => {
|
||||
const link = 'trojan://secret-pw@srv.example:8443?type=ws&security=tls&host=example.com&path=/tj&sni=example.com#imported-trojan';
|
||||
const link =
|
||||
'trojan://secret-pw@srv.example:8443?type=ws&security=tls&host=example.com&path=/tj&sni=example.com#imported-trojan';
|
||||
const out = parseTrojanLink(link);
|
||||
expect(out?.protocol).toBe('trojan');
|
||||
const settings = out?.settings as { servers: Array<{ address: string; port: number; password: string }> };
|
||||
const settings = out?.settings as {
|
||||
servers: Array<{ address: string; port: number; password: string }>;
|
||||
};
|
||||
expect(settings.servers[0].address).toBe('srv.example');
|
||||
expect(settings.servers[0].port).toBe(8443);
|
||||
expect(settings.servers[0].password).toBe('secret-pw');
|
||||
@@ -220,7 +264,9 @@ describe('parseShadowsocksLink', () => {
|
||||
const out = parseShadowsocksLink(link);
|
||||
expect(out?.protocol).toBe('shadowsocks');
|
||||
expect(out?.tag).toBe('imported-ss');
|
||||
const settings = out?.settings as { servers: Array<{ address: string; port: number; method: string; password: string }> };
|
||||
const settings = out?.settings as {
|
||||
servers: Array<{ address: string; port: number; method: string; password: string }>;
|
||||
};
|
||||
expect(settings.servers[0].address).toBe('1.2.3.4');
|
||||
expect(settings.servers[0].port).toBe(8388);
|
||||
expect(settings.servers[0].method).toBe('2022-blake3-aes-128-gcm');
|
||||
@@ -228,15 +274,20 @@ describe('parseShadowsocksLink', () => {
|
||||
});
|
||||
|
||||
it('keeps the port when the link carries a query string (2022 two-key password)', () => {
|
||||
const link = 'ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206LzhsdFZKaU90azE2QmhKZG9WZVRmSkNNUEJlRGhjcmkycTN0dzU1OUZvYz06YUhuTTB6ZnpFaTdRejc5dzlxNWFFWWVQVnpDU0wxaHV4RnZXZFB6OFZHST0@localhost:30757?type=tcp#pahf4urt53';
|
||||
const link =
|
||||
'ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206LzhsdFZKaU90azE2QmhKZG9WZVRmSkNNUEJlRGhjcmkycTN0dzU1OUZvYz06YUhuTTB6ZnpFaTdRejc5dzlxNWFFWWVQVnpDU0wxaHV4RnZXZFB6OFZHST0@localhost:30757?type=tcp#pahf4urt53';
|
||||
const out = parseShadowsocksLink(link);
|
||||
expect(out?.protocol).toBe('shadowsocks');
|
||||
expect(out?.tag).toBe('pahf4urt53');
|
||||
const settings = out?.settings as { servers: Array<{ address: string; port: number; method: string; password: string }> };
|
||||
const settings = out?.settings as {
|
||||
servers: Array<{ address: string; port: number; method: string; password: string }>;
|
||||
};
|
||||
expect(settings.servers[0].address).toBe('localhost');
|
||||
expect(settings.servers[0].port).toBe(30757);
|
||||
expect(settings.servers[0].method).toBe('2022-blake3-aes-256-gcm');
|
||||
expect(settings.servers[0].password).toBe('/8ltVJiOtk16BhJdoVeTfJCMPBeDhcri2q3tw559Foc=:aHnM0zfzEi7Qz79w9q5aEYePVzCSL1huxFvWdPz8VGI=');
|
||||
expect(settings.servers[0].password).toBe(
|
||||
'/8ltVJiOtk16BhJdoVeTfJCMPBeDhcri2q3tw559Foc=:aHnM0zfzEi7Qz79w9q5aEYePVzCSL1huxFvWdPz8VGI=',
|
||||
);
|
||||
});
|
||||
|
||||
it('parses the legacy base64-of-whole form', () => {
|
||||
@@ -244,7 +295,9 @@ describe('parseShadowsocksLink', () => {
|
||||
const inner = Base64.encode('aes-256-gcm:legacypw@10.0.0.1:1080');
|
||||
const link = `ss://${inner}#imported-legacy`;
|
||||
const out = parseShadowsocksLink(link);
|
||||
const settings = out?.settings as { servers: Array<{ address: string; port: number; method: string; password: string }> };
|
||||
const settings = out?.settings as {
|
||||
servers: Array<{ address: string; port: number; method: string; password: string }>;
|
||||
};
|
||||
expect(settings.servers[0].address).toBe('10.0.0.1');
|
||||
expect(settings.servers[0].port).toBe(1080);
|
||||
expect(settings.servers[0].method).toBe('aes-256-gcm');
|
||||
@@ -285,11 +338,12 @@ describe('parseHysteria2Link', () => {
|
||||
});
|
||||
|
||||
it('parses alpn, fingerprint and the salamander UDP mask (fm) — #4760', () => {
|
||||
const link = 'hysteria2://78e7795a209c4c099f896a816fc8448f@news.domain.org:8443?'
|
||||
+ 'alpn=h2%2Chttp%2F1.1&'
|
||||
+ 'fm=%7B%22udp%22%3A%5B%7B%22settings%22%3A%7B%22password%22%3A%22ftwfgb9655hh2mgo%22%7D%2C%22type%22%3A%22salamander%22%7D%5D%7D&'
|
||||
+ 'fp=chrome&obfs=salamander&obfs-password=655hh2mgo&security=tls&sni=news.domain.org'
|
||||
+ '#hy2-ej596ty350qs';
|
||||
const link =
|
||||
'hysteria2://78e7795a209c4c099f896a816fc8448f@news.domain.org:8443?' +
|
||||
'alpn=h2%2Chttp%2F1.1&' +
|
||||
'fm=%7B%22udp%22%3A%5B%7B%22settings%22%3A%7B%22password%22%3A%22ftwfgb9655hh2mgo%22%7D%2C%22type%22%3A%22salamander%22%7D%5D%7D&' +
|
||||
'fp=chrome&obfs=salamander&obfs-password=655hh2mgo&security=tls&sni=news.domain.org' +
|
||||
'#hy2-ej596ty350qs';
|
||||
const out = parseHysteria2Link(link);
|
||||
expect(out).not.toBeNull();
|
||||
const stream = out!.streamSettings as Record<string, unknown>;
|
||||
@@ -306,11 +360,15 @@ describe('parseHysteria2Link', () => {
|
||||
});
|
||||
|
||||
it('reconstructs the salamander mask from standard obfs= without fm=', () => {
|
||||
const link = 'hysteria2://auth@news.domain.org:8443?security=tls&sni=news.domain.org'
|
||||
+ '&obfs=salamander&obfs-password=ftwfgb9655hh2mgo#hy2-std-obfs';
|
||||
const link =
|
||||
'hysteria2://auth@news.domain.org:8443?security=tls&sni=news.domain.org' +
|
||||
'&obfs=salamander&obfs-password=ftwfgb9655hh2mgo#hy2-std-obfs';
|
||||
const out = parseHysteria2Link(link);
|
||||
expect(out).not.toBeNull();
|
||||
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
|
||||
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(finalmask).toBeDefined();
|
||||
const udp = finalmask.udp as Array<Record<string, unknown>>;
|
||||
expect(udp).toHaveLength(1);
|
||||
@@ -325,7 +383,9 @@ describe('parseHysteria2Link', () => {
|
||||
});
|
||||
|
||||
it('ignores obfs=salamander when no obfs-password is present', () => {
|
||||
const out = parseHysteria2Link('hysteria2://auth@srv:443?security=tls&obfs=salamander#hy2-nopw');
|
||||
const out = parseHysteria2Link(
|
||||
'hysteria2://auth@srv:443?security=tls&obfs=salamander#hy2-nopw',
|
||||
);
|
||||
expect(out).not.toBeNull();
|
||||
expect((out!.streamSettings as Record<string, unknown>).finalmask).toBeUndefined();
|
||||
});
|
||||
@@ -337,7 +397,10 @@ describe('parseHysteria2Link', () => {
|
||||
])('accepts the %s form of the obfs pair', (_name, query, want) => {
|
||||
const base = query.includes('obfs=') ? query : `obfs=salamander&${query}`;
|
||||
const out = parseHysteria2Link(`hysteria2://auth@srv:443?security=tls&${base}#hy2-alias`);
|
||||
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
|
||||
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const udp = finalmask.udp as Array<Record<string, unknown>>;
|
||||
expect(udp).toHaveLength(1);
|
||||
expect(udp[0].type).toBe('salamander');
|
||||
@@ -345,12 +408,17 @@ describe('parseHysteria2Link', () => {
|
||||
});
|
||||
|
||||
it('appends the obfs salamander mask alongside a non-salamander fm mask', () => {
|
||||
const fm = encodeURIComponent(JSON.stringify({
|
||||
udp: [{ type: 'mkcp-legacy', settings: { header: 'srtp' } }],
|
||||
}));
|
||||
const fm = encodeURIComponent(
|
||||
JSON.stringify({
|
||||
udp: [{ type: 'mkcp-legacy', settings: { header: 'srtp' } }],
|
||||
}),
|
||||
);
|
||||
const link = `hysteria2://auth@srv:443?security=tls&fm=${fm}&obfs=salamander&obfs-password=added#hy2-append`;
|
||||
const out = parseHysteria2Link(link);
|
||||
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
|
||||
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const udp = finalmask.udp as Array<Record<string, unknown>>;
|
||||
expect(udp).toHaveLength(2);
|
||||
expect(udp[0].type).toBe('mkcp-legacy');
|
||||
@@ -359,20 +427,30 @@ describe('parseHysteria2Link', () => {
|
||||
});
|
||||
|
||||
it('fills the password of a password-less fm salamander mask from obfs', () => {
|
||||
const fm = encodeURIComponent(JSON.stringify({
|
||||
udp: [{ type: 'salamander', settings: {} }],
|
||||
}));
|
||||
const fm = encodeURIComponent(
|
||||
JSON.stringify({
|
||||
udp: [{ type: 'salamander', settings: {} }],
|
||||
}),
|
||||
);
|
||||
const link = `hysteria2://auth@srv:443?security=tls&fm=${fm}&obfs=salamander&obfs-password=fromobfs#hy2-fill`;
|
||||
const out = parseHysteria2Link(link);
|
||||
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
|
||||
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const udp = finalmask.udp as Array<Record<string, unknown>>;
|
||||
expect(udp).toHaveLength(1);
|
||||
expect((udp[0].settings as Record<string, unknown>).password).toBe('fromobfs');
|
||||
});
|
||||
|
||||
it('reconstructs udpHop from the standard mport param', () => {
|
||||
const out = parseHysteria2Link('hysteria2://auth@srv:443?security=tls&mport=20000-50000#hy2-mport');
|
||||
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
|
||||
const out = parseHysteria2Link(
|
||||
'hysteria2://auth@srv:443?security=tls&mport=20000-50000#hy2-mport',
|
||||
);
|
||||
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const quic = finalmask.quicParams as Record<string, unknown>;
|
||||
const udpHop = quic.udpHop as Record<string, unknown>;
|
||||
expect(udpHop.ports).toBe('20000-50000');
|
||||
@@ -380,25 +458,40 @@ describe('parseHysteria2Link', () => {
|
||||
});
|
||||
|
||||
it('lets an fm= udpHop win over mport', () => {
|
||||
const fm = encodeURIComponent(JSON.stringify({
|
||||
quicParams: { udpHop: { ports: '30000-40000', interval: '7-9' } },
|
||||
}));
|
||||
const fm = encodeURIComponent(
|
||||
JSON.stringify({
|
||||
quicParams: { udpHop: { ports: '30000-40000', interval: '7-9' } },
|
||||
}),
|
||||
);
|
||||
const link = `hysteria2://auth@srv:443?security=tls&mport=1-2&fm=${fm}#hy2-mport-fm`;
|
||||
const out = parseHysteria2Link(link);
|
||||
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
|
||||
const udpHop = (finalmask.quicParams as Record<string, unknown>).udpHop as Record<string, unknown>;
|
||||
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const udpHop = (finalmask.quicParams as Record<string, unknown>).udpHop as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(udpHop.ports).toBe('30000-40000');
|
||||
expect(udpHop.interval).toBe('7-9');
|
||||
});
|
||||
|
||||
it('round-trips the salamander packetSize (Gecko) under fm', () => {
|
||||
const fm = encodeURIComponent(JSON.stringify({
|
||||
udp: [{ type: 'salamander', settings: { password: 'ftwfgb9655hh2mgo', packetSize: '100-200' } }],
|
||||
}));
|
||||
const fm = encodeURIComponent(
|
||||
JSON.stringify({
|
||||
udp: [
|
||||
{ type: 'salamander', settings: { password: 'ftwfgb9655hh2mgo', packetSize: '100-200' } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const link = `hysteria2://78e7795a209c4c099f896a816fc8448f@news.domain.org:8443?security=tls&sni=news.domain.org&fm=${fm}#hy2-gecko`;
|
||||
const out = parseHysteria2Link(link);
|
||||
expect(out).not.toBeNull();
|
||||
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
|
||||
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const udp = finalmask.udp as Array<Record<string, unknown>>;
|
||||
const settings = udp[0].settings as Record<string, unknown>;
|
||||
expect(udp[0].type).toBe('salamander');
|
||||
@@ -407,19 +500,24 @@ describe('parseHysteria2Link', () => {
|
||||
});
|
||||
|
||||
it('coerces string quicParams numerics under fm to integers — #5783', () => {
|
||||
const fm = encodeURIComponent(JSON.stringify({
|
||||
quicParams: {
|
||||
keepAlivePeriod: '10s',
|
||||
maxIdleTimeout: '30',
|
||||
initStreamReceiveWindow: 524288,
|
||||
maxIncomingStreams: true,
|
||||
brutalUp: '100 mbps',
|
||||
},
|
||||
}));
|
||||
const fm = encodeURIComponent(
|
||||
JSON.stringify({
|
||||
quicParams: {
|
||||
keepAlivePeriod: '10s',
|
||||
maxIdleTimeout: '30',
|
||||
initStreamReceiveWindow: 524288,
|
||||
maxIncomingStreams: true,
|
||||
brutalUp: '100 mbps',
|
||||
},
|
||||
}),
|
||||
);
|
||||
const link = `hysteria2://78e7795a209c4c099f896a816fc8448f@news.domain.org:8443?security=tls&sni=news.domain.org&fm=${fm}#hy2-quic`;
|
||||
const out = parseHysteria2Link(link);
|
||||
expect(out).not.toBeNull();
|
||||
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
|
||||
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const quic = finalmask.quicParams as Record<string, unknown>;
|
||||
expect(quic.keepAlivePeriod).toBe(10);
|
||||
expect(quic.maxIdleTimeout).toBe(30);
|
||||
@@ -429,20 +527,25 @@ describe('parseHysteria2Link', () => {
|
||||
});
|
||||
|
||||
it('clamps quicParams to the ranges xray accepts and drops junk — #5783', () => {
|
||||
const fm = encodeURIComponent(JSON.stringify({
|
||||
quicParams: {
|
||||
keepAlivePeriod: '1s',
|
||||
maxIdleTimeout: '10m',
|
||||
maxIncomingStreams: 4,
|
||||
initStreamReceiveWindow: 'inf',
|
||||
maxStreamReceiveWindow: -5,
|
||||
initConnectionReceiveWindow: 1e30,
|
||||
},
|
||||
}));
|
||||
const fm = encodeURIComponent(
|
||||
JSON.stringify({
|
||||
quicParams: {
|
||||
keepAlivePeriod: '1s',
|
||||
maxIdleTimeout: '10m',
|
||||
maxIncomingStreams: 4,
|
||||
initStreamReceiveWindow: 'inf',
|
||||
maxStreamReceiveWindow: -5,
|
||||
initConnectionReceiveWindow: 1e30,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const link = `hysteria2://78e7795a209c4c099f896a816fc8448f@news.domain.org:8443?security=tls&sni=news.domain.org&fm=${fm}#hy2-clamp`;
|
||||
const out = parseHysteria2Link(link);
|
||||
expect(out).not.toBeNull();
|
||||
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
|
||||
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const quic = finalmask.quicParams as Record<string, unknown>;
|
||||
expect(quic.keepAlivePeriod).toBe(2);
|
||||
expect(quic.maxIdleTimeout).toBe(120);
|
||||
@@ -453,20 +556,32 @@ describe('parseHysteria2Link', () => {
|
||||
});
|
||||
|
||||
it('round-trips the realm tlsConfig under fm', () => {
|
||||
const fm = encodeURIComponent(JSON.stringify({
|
||||
udp: [{
|
||||
type: 'realm',
|
||||
settings: {
|
||||
url: 'realm://public@example.com/my-realm',
|
||||
stunServers: ['stun.l.google.com:19302'],
|
||||
tlsConfig: { serverName: 'example.com', alpn: ['h3'], fingerprint: 'chrome', allowInsecure: false },
|
||||
},
|
||||
}],
|
||||
}));
|
||||
const fm = encodeURIComponent(
|
||||
JSON.stringify({
|
||||
udp: [
|
||||
{
|
||||
type: 'realm',
|
||||
settings: {
|
||||
url: 'realm://public@example.com/my-realm',
|
||||
stunServers: ['stun.l.google.com:19302'],
|
||||
tlsConfig: {
|
||||
serverName: 'example.com',
|
||||
alpn: ['h3'],
|
||||
fingerprint: 'chrome',
|
||||
allowInsecure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const link = `hysteria2://auth@srv:443?security=tls&sni=srv&fm=${fm}#hy2-realm`;
|
||||
const out = parseHysteria2Link(link);
|
||||
expect(out).not.toBeNull();
|
||||
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
|
||||
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const udp = finalmask.udp as Array<Record<string, unknown>>;
|
||||
const settings = udp[0].settings as Record<string, unknown>;
|
||||
expect(udp[0].type).toBe('realm');
|
||||
@@ -479,7 +594,10 @@ describe('parseHysteria2Link', () => {
|
||||
|
||||
it('defaults alpn to h3 when the link omits it', () => {
|
||||
const out = parseHysteria2Link('hysteria2://auth@srv:443?sni=example.com');
|
||||
const tls = (out!.streamSettings as Record<string, unknown>).tlsSettings as Record<string, unknown>;
|
||||
const tls = (out!.streamSettings as Record<string, unknown>).tlsSettings as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(tls.alpn).toEqual(['h3']);
|
||||
});
|
||||
});
|
||||
@@ -489,15 +607,16 @@ describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => {
|
||||
// Real user-reported link — bundled xhttp knobs via `extra` JSON,
|
||||
// full finalmask via `fm` JSON, reality auth, snake_case
|
||||
// x_padding_bytes alias. All three parse-paths must combine.
|
||||
const link = 'vless://b622ac2f-f155-47db-a3b2-b64e8d7f6342@localhost:37723?'
|
||||
+ 'encryption=none&'
|
||||
+ 'extra=%7B%22scMaxEachPostBytes%22%3A%221000000%22%2C%22scMinPostsIntervalMs%22%3A%2230%22%2C%22xPaddingBytes%22%3A%22100-1000%22%7D&'
|
||||
+ 'fm=%7B%22quicParams%22%3A%7B%22congestion%22%3A%22bbr%22%2C%22maxIdleTimeout%22%3A30%2C%22udpHop%22%3A%7B%22interval%22%3A%225-10%22%2C%22ports%22%3A%2220000-50000%22%7D%7D%7D&'
|
||||
+ 'fp=chrome&host=&mode=auto&path=%2F&'
|
||||
+ 'pbk=nJw4k4CPf5jf64V8nnDwWa8iClDnUvQ1lCI4iKzfJ0o&'
|
||||
+ 'security=reality&sid=14ebccc4d3&sni=aws.amazon.com&'
|
||||
+ 'spx=%2F97L2FjycXEwrE67&type=xhttp&x_padding_bytes=100-1000'
|
||||
+ '#sda-8ud3us6rt';
|
||||
const link =
|
||||
'vless://b622ac2f-f155-47db-a3b2-b64e8d7f6342@localhost:37723?' +
|
||||
'encryption=none&' +
|
||||
'extra=%7B%22scMaxEachPostBytes%22%3A%221000000%22%2C%22scMinPostsIntervalMs%22%3A%2230%22%2C%22xPaddingBytes%22%3A%22100-1000%22%7D&' +
|
||||
'fm=%7B%22quicParams%22%3A%7B%22congestion%22%3A%22bbr%22%2C%22maxIdleTimeout%22%3A30%2C%22udpHop%22%3A%7B%22interval%22%3A%225-10%22%2C%22ports%22%3A%2220000-50000%22%7D%7D%7D&' +
|
||||
'fp=chrome&host=&mode=auto&path=%2F&' +
|
||||
'pbk=nJw4k4CPf5jf64V8nnDwWa8iClDnUvQ1lCI4iKzfJ0o&' +
|
||||
'security=reality&sid=14ebccc4d3&sni=aws.amazon.com&' +
|
||||
'spx=%2F97L2FjycXEwrE67&type=xhttp&x_padding_bytes=100-1000' +
|
||||
'#sda-8ud3us6rt';
|
||||
const parsed = parseVlessLink(link);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.tag).toBe('sda-8ud3us6rt');
|
||||
@@ -527,17 +646,25 @@ describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => {
|
||||
});
|
||||
|
||||
it('falls back to x_padding_bytes when extra has no xPaddingBytes', () => {
|
||||
const link = 'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto&x_padding_bytes=200-2000#t';
|
||||
const link =
|
||||
'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto&x_padding_bytes=200-2000#t';
|
||||
const parsed = parseVlessLink(link);
|
||||
const xhttp = (parsed!.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
|
||||
const xhttp = (parsed!.streamSettings as Record<string, unknown>).xhttpSettings as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(xhttp.xPaddingBytes).toBe('200-2000');
|
||||
});
|
||||
|
||||
it('extra takes precedence — camelCase wins over snake_case alias', () => {
|
||||
const link = 'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto'
|
||||
+ '&xPaddingBytes=900-9000&x_padding_bytes=100-1000#t';
|
||||
const link =
|
||||
'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto' +
|
||||
'&xPaddingBytes=900-9000&x_padding_bytes=100-1000#t';
|
||||
const parsed = parseVlessLink(link);
|
||||
const xhttp = (parsed!.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
|
||||
const xhttp = (parsed!.streamSettings as Record<string, unknown>).xhttpSettings as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(xhttp.xPaddingBytes).toBe('900-9000');
|
||||
});
|
||||
|
||||
@@ -545,13 +672,18 @@ describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => {
|
||||
// The inbound link bundles xmux into `extra` as a nested object
|
||||
// (sub/service.go). It must survive import so the outbound form's
|
||||
// XMUX sub-form populates rather than silently dropping it (#5353).
|
||||
const extra = encodeURIComponent(JSON.stringify({
|
||||
xmux: { maxConcurrency: '8-16', hMaxRequestTimes: '700-1000' },
|
||||
}));
|
||||
const link = 'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto'
|
||||
+ '&extra=' + extra + '#t';
|
||||
const extra = encodeURIComponent(
|
||||
JSON.stringify({
|
||||
xmux: { maxConcurrency: '8-16', hMaxRequestTimes: '700-1000' },
|
||||
}),
|
||||
);
|
||||
const link =
|
||||
'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto' + '&extra=' + extra + '#t';
|
||||
const parsed = parseVlessLink(link);
|
||||
const xhttp = (parsed!.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
|
||||
const xhttp = (parsed!.streamSettings as Record<string, unknown>).xhttpSettings as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const xmux = xhttp.xmux as Record<string, unknown>;
|
||||
expect(xmux).toBeDefined();
|
||||
expect(xmux.maxConcurrency).toBe('8-16');
|
||||
@@ -559,8 +691,9 @@ describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => {
|
||||
});
|
||||
|
||||
it('ignores malformed extra JSON without breaking the rest of the link', () => {
|
||||
const link = 'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto'
|
||||
+ '&extra=not-json&fp=chrome#t';
|
||||
const link =
|
||||
'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto' +
|
||||
'&extra=not-json&fp=chrome#t';
|
||||
const parsed = parseVlessLink(link);
|
||||
expect(parsed).not.toBeNull();
|
||||
const stream = parsed!.streamSettings as Record<string, unknown>;
|
||||
@@ -568,14 +701,23 @@ describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => {
|
||||
});
|
||||
|
||||
it('round-trips ech and pcs from a TLS vless link', () => {
|
||||
const ech = 'AFb+DQBSAAAgACAL7gYwrvaSFCIEs34G3SkfpuIbjMuYQxAiJsPK1oO7cwAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAAMxMjMAAA==';
|
||||
const ech =
|
||||
'AFb+DQBSAAAgACAL7gYwrvaSFCIEs34G3SkfpuIbjMuYQxAiJsPK1oO7cwAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAAMxMjMAAA==';
|
||||
const pcs = '6fbc15ba46dfed152ad6c8d2129dd774707dd667a9ab4965476fa0f79ba82670';
|
||||
const link = 'vless://e3d307ae-c074-4aa3-af08-4f9e0f1d298b@localhost:15282?'
|
||||
+ 'alpn=h3&ech=' + encodeURIComponent(ech) + '&encryption=none&fp=firefox&host=&'
|
||||
+ 'mode=packet-up&path=%2F&pcs=' + pcs + '&security=tls&sni=123&type=xhttp#i5sboxj07w';
|
||||
const link =
|
||||
'vless://e3d307ae-c074-4aa3-af08-4f9e0f1d298b@localhost:15282?' +
|
||||
'alpn=h3&ech=' +
|
||||
encodeURIComponent(ech) +
|
||||
'&encryption=none&fp=firefox&host=&' +
|
||||
'mode=packet-up&path=%2F&pcs=' +
|
||||
pcs +
|
||||
'&security=tls&sni=123&type=xhttp#i5sboxj07w';
|
||||
const parsed = parseVlessLink(link);
|
||||
expect(parsed).not.toBeNull();
|
||||
const tls = (parsed!.streamSettings as Record<string, unknown>).tlsSettings as Record<string, unknown>;
|
||||
const tls = (parsed!.streamSettings as Record<string, unknown>).tlsSettings as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(tls.echConfigList).toBe(ech);
|
||||
expect(tls.pinnedPeerCertSha256).toBe(pcs);
|
||||
expect(tls.serverName).toBe('123');
|
||||
@@ -585,14 +727,17 @@ describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => {
|
||||
|
||||
describe('parseWireguardLink', () => {
|
||||
it('parses a wireguard:// link with percent-encoded secret and publickey', () => {
|
||||
const link = 'wireguard://IKeuy2+BNspvMffiC47z16seLIGxGtbDIYiZcbh9C1U%3D@localhost:22824'
|
||||
+ '?publickey=3CnNsCy74TOlupjaii%2BRFp%2FgDMk5vvUuFD0SNZ%2FGl2s%3D'
|
||||
+ '&address=10.0.0.2%2F32&mtu=1420#-1';
|
||||
const link =
|
||||
'wireguard://IKeuy2+BNspvMffiC47z16seLIGxGtbDIYiZcbh9C1U%3D@localhost:22824' +
|
||||
'?publickey=3CnNsCy74TOlupjaii%2BRFp%2FgDMk5vvUuFD0SNZ%2FGl2s%3D' +
|
||||
'&address=10.0.0.2%2F32&mtu=1420#-1';
|
||||
const out = parseWireguardLink(link);
|
||||
expect(out?.protocol).toBe('wireguard');
|
||||
expect(out?.tag).toBe('-1');
|
||||
const settings = out?.settings as {
|
||||
secretKey: string; address: string[]; mtu: number;
|
||||
secretKey: string;
|
||||
address: string[];
|
||||
mtu: number;
|
||||
peers: Array<{ publicKey: string; endpoint: string; allowedIPs: string[] }>;
|
||||
};
|
||||
expect(settings.secretKey).toBe('IKeuy2+BNspvMffiC47z16seLIGxGtbDIYiZcbh9C1U=');
|
||||
@@ -604,10 +749,11 @@ describe('parseWireguardLink', () => {
|
||||
});
|
||||
|
||||
it('parses reserved, presharedkey and keepalive aliases', () => {
|
||||
const link = 'wireguard://privkey@1.2.3.4:51820'
|
||||
+ '?publickey=peerpub&address=10.0.0.2/32,fd00::2/128'
|
||||
+ '&reserved=1,2,3&presharedkey=psk-secret&persistentkeepalive=25'
|
||||
+ '&allowedips=0.0.0.0/0#wg-peer';
|
||||
const link =
|
||||
'wireguard://privkey@1.2.3.4:51820' +
|
||||
'?publickey=peerpub&address=10.0.0.2/32,fd00::2/128' +
|
||||
'&reserved=1,2,3&presharedkey=psk-secret&persistentkeepalive=25' +
|
||||
'&allowedips=0.0.0.0/0#wg-peer';
|
||||
const out = parseWireguardLink(link);
|
||||
const settings = out?.settings as {
|
||||
reserved: number[];
|
||||
@@ -628,17 +774,29 @@ describe('parseWireguardLink', () => {
|
||||
|
||||
describe('parseOutboundLink dispatcher', () => {
|
||||
it('dispatches vmess via base64 JSON', () => {
|
||||
const json = { v: '2', ps: 'x', add: '1.1.1.1', port: 443, id: '11111111-2222-4333-8444-555555555555', net: 'tcp', tls: 'none' };
|
||||
const json = {
|
||||
v: '2',
|
||||
ps: 'x',
|
||||
add: '1.1.1.1',
|
||||
port: 443,
|
||||
id: '11111111-2222-4333-8444-555555555555',
|
||||
net: 'tcp',
|
||||
tls: 'none',
|
||||
};
|
||||
const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
|
||||
expect(parseOutboundLink(link)?.protocol).toBe('vmess');
|
||||
});
|
||||
|
||||
it('dispatches vless via URL', () => {
|
||||
expect(parseOutboundLink('vless://uuid@host:443?type=tcp&security=none')?.protocol).toBe('vless');
|
||||
expect(parseOutboundLink('vless://uuid@host:443?type=tcp&security=none')?.protocol).toBe(
|
||||
'vless',
|
||||
);
|
||||
});
|
||||
|
||||
it('dispatches wireguard via URL', () => {
|
||||
expect(parseOutboundLink('wireguard://pk@host:22824?publickey=pub&address=10.0.0.2/32')?.protocol).toBe('wireguard');
|
||||
expect(
|
||||
parseOutboundLink('wireguard://pk@host:22824?publickey=pub&address=10.0.0.2/32')?.protocol,
|
||||
).toBe('wireguard');
|
||||
});
|
||||
|
||||
it('returns null for an unknown scheme', () => {
|
||||
|
||||
@@ -44,12 +44,15 @@ describe('propagateOutboundTagRename', () => {
|
||||
|
||||
it('updates sockopt dialerProxy references in other outbounds', () => {
|
||||
const t = baseTemplate();
|
||||
(t.outbounds![1] as { streamSettings?: { sockopt?: { dialerProxy?: string } } }).streamSettings = {
|
||||
(
|
||||
t.outbounds![1] as { streamSettings?: { sockopt?: { dialerProxy?: string } } }
|
||||
).streamSettings = {
|
||||
sockopt: { dialerProxy: 'To-External-Proxy' },
|
||||
};
|
||||
propagateOutboundTagRename(t, 'To-External-Proxy', 'external-vps');
|
||||
const dialerProxy = (t.outbounds![1] as { streamSettings?: { sockopt?: { dialerProxy?: string } } })
|
||||
.streamSettings?.sockopt?.dialerProxy;
|
||||
const dialerProxy = (
|
||||
t.outbounds![1] as { streamSettings?: { sockopt?: { dialerProxy?: string } } }
|
||||
).streamSettings?.sockopt?.dialerProxy;
|
||||
expect(dialerProxy).toBe('external-vps');
|
||||
});
|
||||
|
||||
|
||||
@@ -47,7 +47,9 @@ describe('OutboundsTab hidden-loopback index mapping', () => {
|
||||
const tableRows = tbody?.querySelectorAll('tr.ant-table-row') ?? [];
|
||||
expect(tableRows.length).toBe(2);
|
||||
|
||||
const checkButton = tableRows[1].querySelector('button[aria-label="Check"]') as HTMLButtonElement;
|
||||
const checkButton = tableRows[1].querySelector(
|
||||
'button[aria-label="Check"]',
|
||||
) as HTMLButtonElement;
|
||||
fireEvent.click(checkButton);
|
||||
|
||||
expect(onTest).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -16,28 +16,31 @@ import {
|
||||
// legacy class migration and verified byte-equal to the legacy Inbound
|
||||
// class instance methods. Drift past this baseline is a regression.
|
||||
|
||||
const fixtures = import.meta.glob<unknown>(
|
||||
'./golden/fixtures/inbound/*.json',
|
||||
{ eager: true, import: 'default' },
|
||||
);
|
||||
const fixtures = import.meta.glob<unknown>('./golden/fixtures/inbound/*.json', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
});
|
||||
|
||||
interface FixtureShape { protocol: string; settings: Record<string, unknown> }
|
||||
interface FixtureShape {
|
||||
protocol: string;
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const STREAM_CASES: { network: string; security: string }[] = [
|
||||
{ network: 'tcp', security: 'none' },
|
||||
{ network: 'tcp', security: 'tls' },
|
||||
{ network: 'tcp', security: 'reality' },
|
||||
{ network: 'ws', security: 'none' },
|
||||
{ network: 'ws', security: 'tls' },
|
||||
{ network: 'grpc', security: 'none' },
|
||||
{ network: 'grpc', security: 'tls' },
|
||||
{ network: 'grpc', security: 'reality' },
|
||||
{ network: 'kcp', security: 'none' },
|
||||
{ network: 'tcp', security: 'none' },
|
||||
{ network: 'tcp', security: 'tls' },
|
||||
{ network: 'tcp', security: 'reality' },
|
||||
{ network: 'ws', security: 'none' },
|
||||
{ network: 'ws', security: 'tls' },
|
||||
{ network: 'grpc', security: 'none' },
|
||||
{ network: 'grpc', security: 'tls' },
|
||||
{ network: 'grpc', security: 'reality' },
|
||||
{ network: 'kcp', security: 'none' },
|
||||
{ network: 'httpupgrade', security: 'none' },
|
||||
{ network: 'httpupgrade', security: 'tls' },
|
||||
{ network: 'xhttp', security: 'none' },
|
||||
{ network: 'xhttp', security: 'tls' },
|
||||
{ network: 'xhttp', security: 'reality' },
|
||||
{ network: 'xhttp', security: 'none' },
|
||||
{ network: 'xhttp', security: 'tls' },
|
||||
{ network: 'xhttp', security: 'reality' },
|
||||
];
|
||||
|
||||
function fixtureName(path: string): string {
|
||||
@@ -51,7 +54,6 @@ describe('protocol capability predicates', () => {
|
||||
const fix = raw as FixtureShape;
|
||||
|
||||
for (const stream of STREAM_CASES) {
|
||||
|
||||
it(`${name} :: ${stream.network}/${stream.security}`, () => {
|
||||
const values = {
|
||||
protocol: fix.protocol,
|
||||
|
||||
@@ -6,10 +6,10 @@ import { InboundSettingsSchema } from '@/schemas/protocols';
|
||||
// import.meta.glob (eager, default-import) gives us {path: parsedJson} at
|
||||
// compile time — no fs, no @types/node. Vitest inherits the vite/client
|
||||
// shape so this stays typed.
|
||||
const inboundFixtures = import.meta.glob<unknown>(
|
||||
'./golden/fixtures/inbound/*.json',
|
||||
{ eager: true, import: 'default' },
|
||||
);
|
||||
const inboundFixtures = import.meta.glob<unknown>('./golden/fixtures/inbound/*.json', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
});
|
||||
|
||||
function fixtureName(path: string): string {
|
||||
const file = path.split('/').pop() ?? path;
|
||||
@@ -18,7 +18,10 @@ function fixtureName(path: string): string {
|
||||
|
||||
describe('InboundSettingsSchema fixtures', () => {
|
||||
const entries = Object.entries(inboundFixtures).sort(([a], [b]) => a.localeCompare(b));
|
||||
expect(entries.length, 'expected at least one fixture under golden/fixtures/inbound').toBeGreaterThan(0);
|
||||
expect(
|
||||
entries.length,
|
||||
'expected at least one fixture under golden/fixtures/inbound',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
for (const [path, raw] of entries) {
|
||||
it(`parses ${fixtureName(path)} byte-stably`, () => {
|
||||
@@ -37,7 +40,8 @@ describe('InboundSettingsSchema coercions', () => {
|
||||
protocol: 'vmess',
|
||||
settings: { clients: [{ id: 'u1', email: 'a@b.c', tgId: '12345' }] },
|
||||
});
|
||||
if (parsed.protocol !== 'vmess') throw new Error('discriminator narrowed to the wrong protocol');
|
||||
if (parsed.protocol !== 'vmess')
|
||||
throw new Error('discriminator narrowed to the wrong protocol');
|
||||
const client = parsed.settings.clients[0];
|
||||
expect(client.alterId).toBe(0); // .default(0) injected for omitted field
|
||||
expect(client.tgId).toBe(12345); // string -> number transform
|
||||
|
||||
@@ -50,6 +50,8 @@ describe('RemarkTemplateField', () => {
|
||||
});
|
||||
|
||||
it('previews metadata fields with metadata-safe tokens only', () => {
|
||||
expect(previewRemark('{{EMAIL}}/{{TRAFFIC_LEFT}}', SUBSCRIPTION_METADATA_VARIABLES, true)).toBe('john/{{TRAFFIC_LEFT}}');
|
||||
expect(previewRemark('{{EMAIL}}/{{TRAFFIC_LEFT}}', SUBSCRIPTION_METADATA_VARIABLES, true)).toBe(
|
||||
'john/{{TRAFFIC_LEFT}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,9 @@ describe('remark variables', () => {
|
||||
it('previewRemark substitutes known tokens and drops unknown', () => {
|
||||
expect(previewRemark('plain text')).toBe('plain text');
|
||||
expect(previewRemark('{{EMAIL}}')).toBe('john');
|
||||
expect(previewRemark('{{EMAIL}} · {{TRAFFIC_LEFT}} · {{DAYS_LEFT}}d')).toBe('john · 41.60GB · 12d');
|
||||
expect(previewRemark('{{EMAIL}} · {{TRAFFIC_LEFT}} · {{DAYS_LEFT}}d')).toBe(
|
||||
'john · 41.60GB · 12d',
|
||||
);
|
||||
expect(previewRemark('{{NOT_A_TOKEN}}')).toBe('');
|
||||
});
|
||||
|
||||
|
||||
@@ -43,7 +43,9 @@ function Harness({ onSubmit }: { onSubmit: (values: Values) => void }) {
|
||||
>
|
||||
<InputNumber aria-label="bytes" />
|
||||
</FormField>
|
||||
<button type="button" onClick={methods.handleSubmit(onSubmit)}>Save</button>
|
||||
<button type="button" onClick={methods.handleSubmit(onSubmit)}>
|
||||
Save
|
||||
</button>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,14 @@ function tpl(
|
||||
|
||||
describe('routing default outbound', () => {
|
||||
it('reads first outbound tag', () => {
|
||||
expect(getDefaultOutboundTag(tpl([{ tag: 'warp', protocol: 'socks' }, { tag: 'direct', protocol: 'freedom' }]))).toBe('warp');
|
||||
expect(
|
||||
getDefaultOutboundTag(
|
||||
tpl([
|
||||
{ tag: 'warp', protocol: 'socks' },
|
||||
{ tag: 'direct', protocol: 'freedom' },
|
||||
]),
|
||||
),
|
||||
).toBe('warp');
|
||||
expect(getDefaultOutboundTag(tpl([]))).toBe('direct');
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,9 @@ describe('RoutingTab hidden-loopback index mapping', () => {
|
||||
fireEvent.click(switches[1]);
|
||||
|
||||
expect(setTemplateSettings).toHaveBeenCalledTimes(1);
|
||||
const updater = setTemplateSettings.mock.calls[0][0] as (prev: XraySettingsValue) => XraySettingsValue;
|
||||
const updater = setTemplateSettings.mock.calls[0][0] as (
|
||||
prev: XraySettingsValue,
|
||||
) => XraySettingsValue;
|
||||
const next = updater(initial);
|
||||
const rules = (next.routing as { rules: Array<{ enabled?: boolean }> }).rules;
|
||||
|
||||
|
||||
@@ -22,7 +22,10 @@ describe('outbound deletion', () => {
|
||||
it('drops a rule whose only destination was the deleted outbound', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }],
|
||||
routing: { rules: [{ type: 'field', inboundTag: ['in-443'], outboundTag: 'proxy-us' }], balancers: [] },
|
||||
routing: {
|
||||
rules: [{ type: 'field', inboundTag: ['in-443'], outboundTag: 'proxy-us' }],
|
||||
balancers: [],
|
||||
},
|
||||
});
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.routing!.rules).toEqual([]);
|
||||
@@ -118,7 +121,9 @@ describe('outbound deletion', () => {
|
||||
applyOutboundDeletion(tt, 1);
|
||||
expect(tt.burstObservatory).toBeUndefined();
|
||||
expect((tt.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['lp-out']);
|
||||
expect(tt.routing!.balancers).toEqual([{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }]);
|
||||
expect(tt.routing!.balancers).toEqual([
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('cascade-keeps burst observer when leastPing is removed but leastLoad remains', () => {
|
||||
@@ -138,8 +143,12 @@ describe('outbound deletion', () => {
|
||||
expect(impact.burst).toBe(false);
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.observatory).toBeUndefined();
|
||||
expect((tt.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['ll-out']);
|
||||
expect(tt.routing!.balancers).toEqual([{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }]);
|
||||
expect((tt.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual([
|
||||
'll-out',
|
||||
]);
|
||||
expect(tt.routing!.balancers).toEqual([
|
||||
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears a fallbackTag and a dialerProxy pointing at the deleted outbound', () => {
|
||||
@@ -231,7 +240,12 @@ describe('outbound deletion', () => {
|
||||
|
||||
it('removes a referenced outbound with no rules and reports an empty impact', () => {
|
||||
const tt = tpl({ outbounds: [{ tag: 'lonely' }], routing: { rules: [], balancers: [] } });
|
||||
expect(planOutboundDeletion(tt, 0)).toEqual({ rules: [], balancers: [], observatory: false, burst: false });
|
||||
expect(planOutboundDeletion(tt, 0)).toEqual({
|
||||
rules: [],
|
||||
balancers: [],
|
||||
observatory: false,
|
||||
burst: false,
|
||||
});
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.outbounds).toEqual([]);
|
||||
});
|
||||
@@ -239,7 +253,10 @@ describe('outbound deletion', () => {
|
||||
it('uses ruleTag as the impact label when present', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'x' }],
|
||||
routing: { rules: [{ type: 'field', ruleTag: 'block-ads', outboundTag: 'x' }], balancers: [] },
|
||||
routing: {
|
||||
rules: [{ type: 'field', ruleTag: 'block-ads', outboundTag: 'x' }],
|
||||
balancers: [],
|
||||
},
|
||||
});
|
||||
expect(planOutboundDeletion(tt, 0).rules[0].label).toBe('block-ads');
|
||||
});
|
||||
@@ -275,7 +292,9 @@ describe('outbound deletion', () => {
|
||||
const planned = make();
|
||||
const applied = make();
|
||||
const total = planned.routing!.rules!.length;
|
||||
const removed = planOutboundDeletion(planned, 0).rules.filter((r) => r.fate === 'removed').length;
|
||||
const removed = planOutboundDeletion(planned, 0).rules.filter(
|
||||
(r) => r.fate === 'removed',
|
||||
).length;
|
||||
applyOutboundDeletion(applied, 0);
|
||||
expect(applied.routing!.rules!.length).toBe(total - removed);
|
||||
});
|
||||
@@ -284,7 +303,10 @@ describe('outbound deletion', () => {
|
||||
describe('balancer deletion', () => {
|
||||
it('drops a rule whose only destination was the deleted balancer', () => {
|
||||
const tt = tpl({
|
||||
routing: { rules: [{ type: 'field', inboundTag: ['in'], balancerTag: 'pool' }], balancers: [{ tag: 'pool', selector: ['a'] }] },
|
||||
routing: {
|
||||
rules: [{ type: 'field', inboundTag: ['in'], balancerTag: 'pool' }],
|
||||
balancers: [{ tag: 'pool', selector: ['a'] }],
|
||||
},
|
||||
});
|
||||
applyBalancerDeletion(tt, 0);
|
||||
expect(tt.routing!.balancers).toEqual([]);
|
||||
@@ -293,7 +315,10 @@ describe('balancer deletion', () => {
|
||||
|
||||
it('keeps a rule that still has an outbound, dropping only the dead balancerTag', () => {
|
||||
const tt = tpl({
|
||||
routing: { rules: [{ type: 'field', outboundTag: 'direct', balancerTag: 'pool' }], balancers: [{ tag: 'pool', selector: ['a'] }] },
|
||||
routing: {
|
||||
rules: [{ type: 'field', outboundTag: 'direct', balancerTag: 'pool' }],
|
||||
balancers: [{ tag: 'pool', selector: ['a'] }],
|
||||
},
|
||||
});
|
||||
applyBalancerDeletion(tt, 0);
|
||||
expect(tt.routing!.rules).toHaveLength(1);
|
||||
@@ -303,7 +328,10 @@ describe('balancer deletion', () => {
|
||||
|
||||
it('reports and removes the observer when deleting the last leastPing balancer', () => {
|
||||
const tt = tpl({
|
||||
routing: { rules: [], balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }] },
|
||||
routing: {
|
||||
rules: [],
|
||||
balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }],
|
||||
},
|
||||
observatory: { subjectSelector: ['a'] },
|
||||
});
|
||||
expect(planBalancerDeletion(tt, 0).observatory).toBe(true);
|
||||
@@ -314,7 +342,10 @@ describe('balancer deletion', () => {
|
||||
|
||||
it('reports and removes the burst observer when deleting the last leastLoad balancer', () => {
|
||||
const tt = tpl({
|
||||
routing: { rules: [], balancers: [{ tag: 'll', selector: ['a'], strategy: { type: 'leastLoad' } }] },
|
||||
routing: {
|
||||
rules: [],
|
||||
balancers: [{ tag: 'll', selector: ['a'], strategy: { type: 'leastLoad' } }],
|
||||
},
|
||||
burstObservatory: { subjectSelector: ['a'] },
|
||||
});
|
||||
expect(planBalancerDeletion(tt, 0).burst).toBe(true);
|
||||
@@ -351,7 +382,9 @@ describe('balancer deletion', () => {
|
||||
applyBalancerDeletion(tt, 1);
|
||||
expect(tt.burstObservatory).toBeUndefined();
|
||||
expect((tt.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['lp-out']);
|
||||
expect(tt.routing!.balancers).toEqual([{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }]);
|
||||
expect(tt.routing!.balancers).toEqual([
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps burst observer when deleting leastPing but a burst-required balancer remains', () => {
|
||||
@@ -370,13 +403,20 @@ describe('balancer deletion', () => {
|
||||
expect(impact.observatory).toBe(false);
|
||||
applyBalancerDeletion(tt, 0);
|
||||
expect(tt.observatory).toBeUndefined();
|
||||
expect((tt.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['ll-out']);
|
||||
expect(tt.routing!.balancers).toEqual([{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }]);
|
||||
expect((tt.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual([
|
||||
'll-out',
|
||||
]);
|
||||
expect(tt.routing!.balancers).toEqual([
|
||||
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not report rules when the deleted balancer is unreferenced', () => {
|
||||
const tt = tpl({
|
||||
routing: { rules: [{ type: 'field', inboundTag: ['in'], outboundTag: 'direct' }], balancers: [{ tag: 'pool', selector: ['a'] }] },
|
||||
routing: {
|
||||
rules: [{ type: 'field', inboundTag: ['in'], outboundTag: 'direct' }],
|
||||
balancers: [{ tag: 'pool', selector: ['a'] }],
|
||||
},
|
||||
});
|
||||
expect(planBalancerDeletion(tt, 0).rules).toEqual([]);
|
||||
applyBalancerDeletion(tt, 0);
|
||||
|
||||
@@ -3,10 +3,10 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { RuleObjectSchema } from '@/schemas/routing';
|
||||
|
||||
const fixtures = import.meta.glob<unknown>(
|
||||
'./golden/fixtures/rule/*.json',
|
||||
{ eager: true, import: 'default' },
|
||||
);
|
||||
const fixtures = import.meta.glob<unknown>('./golden/fixtures/rule/*.json', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
});
|
||||
|
||||
function fixtureName(path: string): string {
|
||||
const file = path.split('/').pop() ?? path;
|
||||
@@ -15,7 +15,10 @@ function fixtureName(path: string): string {
|
||||
|
||||
describe('RuleObjectSchema fixtures', () => {
|
||||
const entries = Object.entries(fixtures).sort(([a], [b]) => a.localeCompare(b));
|
||||
expect(entries.length, 'expected at least one fixture under golden/fixtures/rule').toBeGreaterThan(0);
|
||||
expect(
|
||||
entries.length,
|
||||
'expected at least one fixture under golden/fixtures/rule',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
for (const [path, raw] of entries) {
|
||||
it(`parses ${fixtureName(path)} byte-stably`, () => {
|
||||
|
||||
@@ -4,10 +4,10 @@ import { describe, expect, it } from 'vitest';
|
||||
import { SecuritySettingsSchema } from '@/schemas/protocols';
|
||||
import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality';
|
||||
|
||||
const securityFixtures = import.meta.glob<unknown>(
|
||||
'./golden/fixtures/security/*.json',
|
||||
{ eager: true, import: 'default' },
|
||||
);
|
||||
const securityFixtures = import.meta.glob<unknown>('./golden/fixtures/security/*.json', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
});
|
||||
|
||||
function fixtureName(path: string): string {
|
||||
const file = path.split('/').pop() ?? path;
|
||||
@@ -16,7 +16,10 @@ function fixtureName(path: string): string {
|
||||
|
||||
describe('SecuritySettingsSchema fixtures', () => {
|
||||
const entries = Object.entries(securityFixtures).sort(([a], [b]) => a.localeCompare(b));
|
||||
expect(entries.length, 'expected at least one fixture under golden/fixtures/security').toBeGreaterThan(0);
|
||||
expect(
|
||||
entries.length,
|
||||
'expected at least one fixture under golden/fixtures/security',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
for (const [path, raw] of entries) {
|
||||
it(`parses ${fixtureName(path)} byte-stably`, () => {
|
||||
|
||||
@@ -11,7 +11,10 @@ describe('subUpdates range', () => {
|
||||
|
||||
it('rejects values outside the backend range', () => {
|
||||
for (const v of [-1, 525601, 1.5]) {
|
||||
expect(AllSettingSchema.safeParse({ subUpdates: v }).success, `subUpdates=${v} should be invalid`).toBe(false);
|
||||
expect(
|
||||
AllSettingSchema.safeParse({ subUpdates: v }).success,
|
||||
`subUpdates=${v} should be invalid`,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,11 +13,19 @@ if (typeof globalThis.localStorage === 'undefined') {
|
||||
const store = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
|
||||
setItem: (k: string, v: string) => { store.set(k, String(v)); },
|
||||
removeItem: (k: string) => { store.delete(k); },
|
||||
clear: () => { store.clear(); },
|
||||
setItem: (k: string, v: string) => {
|
||||
store.set(k, String(v));
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
store.delete(k);
|
||||
},
|
||||
clear: () => {
|
||||
store.clear();
|
||||
},
|
||||
key: (i: number) => Array.from(store.keys())[i] ?? null,
|
||||
get length() { return store.size; },
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
} as Storage;
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: storage, configurable: true });
|
||||
Object.defineProperty(globalThis, 'sessionStorage', { value: storage, configurable: true });
|
||||
@@ -52,7 +60,8 @@ if (!Element.prototype.scrollIntoView) {
|
||||
// Design and CodeMirror use these APIs for layout, so supply harmless test
|
||||
// fallbacks instead of emitting noisy "Not implemented" errors.
|
||||
const nativeGetComputedStyle = window.getComputedStyle.bind(window);
|
||||
window.getComputedStyle = ((element: Element) => nativeGetComputedStyle(element)) as typeof window.getComputedStyle;
|
||||
window.getComputedStyle = ((element: Element) =>
|
||||
nativeGetComputedStyle(element)) as typeof window.getComputedStyle;
|
||||
|
||||
if (!Range.prototype.getClientRects) {
|
||||
Range.prototype.getClientRects = () => [] as unknown as DOMRectList;
|
||||
@@ -89,8 +98,6 @@ 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') ? [] : {},
|
||||
));
|
||||
vi.spyOn(HttpUtil, 'get').mockImplementation(
|
||||
async (url: string) => new Msg(true, '', url.includes('/panel/api/inbounds/options') ? [] : {}),
|
||||
);
|
||||
|
||||
@@ -3,10 +3,10 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream';
|
||||
|
||||
const fixtures = import.meta.glob<unknown>(
|
||||
'./golden/fixtures/sockopt/*.json',
|
||||
{ eager: true, import: 'default' },
|
||||
);
|
||||
const fixtures = import.meta.glob<unknown>('./golden/fixtures/sockopt/*.json', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
});
|
||||
|
||||
function fixtureName(path: string): string {
|
||||
const file = path.split('/').pop() ?? path;
|
||||
@@ -15,7 +15,10 @@ function fixtureName(path: string): string {
|
||||
|
||||
describe('SockoptStreamSettingsSchema fixtures', () => {
|
||||
const entries = Object.entries(fixtures).sort(([a], [b]) => a.localeCompare(b));
|
||||
expect(entries.length, 'expected at least one fixture under golden/fixtures/sockopt').toBeGreaterThan(0);
|
||||
expect(
|
||||
entries.length,
|
||||
'expected at least one fixture under golden/fixtures/sockopt',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
for (const [path, raw] of entries) {
|
||||
it(`parses ${fixtureName(path)} byte-stably`, () => {
|
||||
|
||||
@@ -9,7 +9,9 @@ function Story() {
|
||||
}
|
||||
|
||||
function StorybookTheme({ theme }: { theme: 'light' | 'dark' }) {
|
||||
return withTheme(Story, { globals: { theme } } as Partial<Parameters<typeof withTheme>[1]> as Parameters<typeof withTheme>[1]);
|
||||
return withTheme(Story, { globals: { theme } } as Partial<
|
||||
Parameters<typeof withTheme>[1]
|
||||
> as Parameters<typeof withTheme>[1]);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -41,7 +43,11 @@ test('preserves unrelated body classes when applying the panel theme', () => {
|
||||
message.className = 'message-fixture';
|
||||
document.body.append(message);
|
||||
|
||||
render(<ThemeProvider><div>Panel</div></ThemeProvider>);
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<div>Panel</div>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(document.body.classList.contains('panel-fixture')).toBe(true);
|
||||
expect(document.body.classList.contains('dark')).toBe(true);
|
||||
|
||||
@@ -23,7 +23,9 @@ describe('validateRealityTarget', () => {
|
||||
});
|
||||
|
||||
it('rejects host without port', () => {
|
||||
expect(validateRealityTarget('play.google.com')).toBe('pages.inbounds.form.realityTargetNeedsPort');
|
||||
expect(validateRealityTarget('play.google.com')).toBe(
|
||||
'pages.inbounds.form.realityTargetNeedsPort',
|
||||
);
|
||||
expect(validateRealityTarget('')).toBe('pages.inbounds.form.realityTargetRequired');
|
||||
});
|
||||
});
|
||||
@@ -88,18 +90,21 @@ describe('validateRealityMaxClientVer', () => {
|
||||
|
||||
describe('normalizeXhttpForWire stream-one', () => {
|
||||
it('drops packet-up and stream-up-only fields on inbound', () => {
|
||||
const out = normalizeXhttpForWire({
|
||||
path: '/app',
|
||||
host: 'play.google.com',
|
||||
mode: 'stream-one',
|
||||
xPaddingBytes: '100-1000',
|
||||
scMaxEachPostBytes: '1000000',
|
||||
scMinPostsIntervalMs: '30',
|
||||
scMaxBufferedPosts: 30,
|
||||
scStreamUpServerSecs: '20-80',
|
||||
enableXmux: false,
|
||||
headers: {},
|
||||
}, 'inbound');
|
||||
const out = normalizeXhttpForWire(
|
||||
{
|
||||
path: '/app',
|
||||
host: 'play.google.com',
|
||||
mode: 'stream-one',
|
||||
xPaddingBytes: '100-1000',
|
||||
scMaxEachPostBytes: '1000000',
|
||||
scMinPostsIntervalMs: '30',
|
||||
scMaxBufferedPosts: 30,
|
||||
scStreamUpServerSecs: '20-80',
|
||||
enableXmux: false,
|
||||
headers: {},
|
||||
},
|
||||
'inbound',
|
||||
);
|
||||
|
||||
expect(out).toMatchObject({
|
||||
path: '/app',
|
||||
@@ -116,59 +121,74 @@ describe('normalizeXhttpForWire stream-one', () => {
|
||||
});
|
||||
|
||||
it('preserves non-default scMinPostsIntervalMs on inbound for subscriptions', () => {
|
||||
const out = normalizeXhttpForWire({
|
||||
path: '/app',
|
||||
mode: 'packet-up',
|
||||
scMinPostsIntervalMs: '50-150',
|
||||
enableXmux: false,
|
||||
}, 'inbound');
|
||||
const out = normalizeXhttpForWire(
|
||||
{
|
||||
path: '/app',
|
||||
mode: 'packet-up',
|
||||
scMinPostsIntervalMs: '50-150',
|
||||
enableXmux: false,
|
||||
},
|
||||
'inbound',
|
||||
);
|
||||
|
||||
expect(out.scMinPostsIntervalMs).toBe('50-150');
|
||||
});
|
||||
|
||||
it('strips empty scMinPostsIntervalMs on inbound', () => {
|
||||
const out = normalizeXhttpForWire({
|
||||
path: '/app',
|
||||
mode: 'packet-up',
|
||||
scMinPostsIntervalMs: '',
|
||||
enableXmux: false,
|
||||
}, 'inbound');
|
||||
const out = normalizeXhttpForWire(
|
||||
{
|
||||
path: '/app',
|
||||
mode: 'packet-up',
|
||||
scMinPostsIntervalMs: '',
|
||||
enableXmux: false,
|
||||
},
|
||||
'inbound',
|
||||
);
|
||||
|
||||
expect(out).not.toHaveProperty('scMinPostsIntervalMs');
|
||||
});
|
||||
|
||||
it('keeps xmux on outbound stream-one', () => {
|
||||
const out = normalizeXhttpForWire({
|
||||
path: '/app',
|
||||
mode: 'stream-one',
|
||||
xPaddingBytes: '100-1000',
|
||||
xmux: { maxConcurrency: '16-32' },
|
||||
scMaxEachPostBytes: '1000000',
|
||||
}, 'outbound');
|
||||
const out = normalizeXhttpForWire(
|
||||
{
|
||||
path: '/app',
|
||||
mode: 'stream-one',
|
||||
xPaddingBytes: '100-1000',
|
||||
xmux: { maxConcurrency: '16-32' },
|
||||
scMaxEachPostBytes: '1000000',
|
||||
},
|
||||
'outbound',
|
||||
);
|
||||
|
||||
expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
|
||||
expect(out).not.toHaveProperty('scMaxEachPostBytes');
|
||||
});
|
||||
|
||||
it('keeps inbound xmux when enableXmux is on (stored for subscription extra; stripped from xray config on Go side)', () => {
|
||||
const out = normalizeXhttpForWire({
|
||||
path: '/app',
|
||||
mode: 'auto',
|
||||
enableXmux: true,
|
||||
xmux: { maxConcurrency: '16-32' },
|
||||
}, 'inbound');
|
||||
const out = normalizeXhttpForWire(
|
||||
{
|
||||
path: '/app',
|
||||
mode: 'auto',
|
||||
enableXmux: true,
|
||||
xmux: { maxConcurrency: '16-32' },
|
||||
},
|
||||
'inbound',
|
||||
);
|
||||
|
||||
expect(out).not.toHaveProperty('enableXmux');
|
||||
expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
|
||||
});
|
||||
|
||||
it('drops inbound xmux when enableXmux is off', () => {
|
||||
const out = normalizeXhttpForWire({
|
||||
path: '/app',
|
||||
mode: 'auto',
|
||||
enableXmux: false,
|
||||
xmux: { maxConcurrency: '16-32' },
|
||||
}, 'inbound');
|
||||
const out = normalizeXhttpForWire(
|
||||
{
|
||||
path: '/app',
|
||||
mode: 'auto',
|
||||
enableXmux: false,
|
||||
xmux: { maxConcurrency: '16-32' },
|
||||
},
|
||||
'inbound',
|
||||
);
|
||||
|
||||
expect(out).not.toHaveProperty('enableXmux');
|
||||
expect(out).not.toHaveProperty('xmux');
|
||||
@@ -176,12 +196,15 @@ describe('normalizeXhttpForWire stream-one', () => {
|
||||
|
||||
// xray-core rejects a config with both maxConnections and maxConcurrency.
|
||||
it('drops maxConcurrency when maxConnections is set (xray-core exclusivity)', () => {
|
||||
const out = normalizeXhttpForWire({
|
||||
path: '/app',
|
||||
mode: 'auto',
|
||||
enableXmux: true,
|
||||
xmux: { maxConcurrency: '16-32', maxConnections: 4, hKeepAlivePeriod: 30 },
|
||||
}, 'inbound');
|
||||
const out = normalizeXhttpForWire(
|
||||
{
|
||||
path: '/app',
|
||||
mode: 'auto',
|
||||
enableXmux: true,
|
||||
xmux: { maxConcurrency: '16-32', maxConnections: 4, hKeepAlivePeriod: 30 },
|
||||
},
|
||||
'inbound',
|
||||
);
|
||||
|
||||
const xmux = out.xmux as Record<string, unknown>;
|
||||
expect(xmux).not.toHaveProperty('maxConcurrency');
|
||||
@@ -190,11 +213,14 @@ describe('normalizeXhttpForWire stream-one', () => {
|
||||
});
|
||||
|
||||
it('keeps maxConcurrency when maxConnections is 0/unset', () => {
|
||||
const out = normalizeXhttpForWire({
|
||||
path: '/app',
|
||||
mode: 'stream-one',
|
||||
xmux: { maxConcurrency: '16-32', maxConnections: 0 },
|
||||
}, 'outbound');
|
||||
const out = normalizeXhttpForWire(
|
||||
{
|
||||
path: '/app',
|
||||
mode: 'stream-one',
|
||||
xmux: { maxConcurrency: '16-32', maxConnections: 0 },
|
||||
},
|
||||
'outbound',
|
||||
);
|
||||
|
||||
const xmux = out.xmux as Record<string, unknown>;
|
||||
expect(xmux.maxConcurrency).toBe('16-32');
|
||||
@@ -202,11 +228,14 @@ describe('normalizeXhttpForWire stream-one', () => {
|
||||
});
|
||||
|
||||
it('applies xmux exclusivity on the outbound side too', () => {
|
||||
const out = normalizeXhttpForWire({
|
||||
path: '/app',
|
||||
mode: 'stream-one',
|
||||
xmux: { maxConcurrency: '16-32', maxConnections: '8' },
|
||||
}, 'outbound');
|
||||
const out = normalizeXhttpForWire(
|
||||
{
|
||||
path: '/app',
|
||||
mode: 'stream-one',
|
||||
xmux: { maxConcurrency: '16-32', maxConnections: '8' },
|
||||
},
|
||||
'outbound',
|
||||
);
|
||||
|
||||
const xmux = out.xmux as Record<string, unknown>;
|
||||
expect(xmux).not.toHaveProperty('maxConcurrency');
|
||||
@@ -222,12 +251,15 @@ describe('normalizeXhttpForWire stream-one', () => {
|
||||
expect(XMUX_FRESH_DEFAULTS.maxConnections).toBe(3);
|
||||
expect(XMUX_FRESH_DEFAULTS.maxConcurrency).toBe('');
|
||||
|
||||
const out = normalizeXhttpForWire({
|
||||
path: '/app',
|
||||
mode: 'stream-one',
|
||||
enableXmux: true,
|
||||
xmux: XMUX_FRESH_DEFAULTS,
|
||||
}, 'outbound');
|
||||
const out = normalizeXhttpForWire(
|
||||
{
|
||||
path: '/app',
|
||||
mode: 'stream-one',
|
||||
enableXmux: true,
|
||||
xmux: XMUX_FRESH_DEFAULTS,
|
||||
},
|
||||
'outbound',
|
||||
);
|
||||
|
||||
const xmux = out.xmux as Record<string, unknown>;
|
||||
expect(xmux.maxConnections).toBe(3);
|
||||
@@ -291,21 +323,24 @@ describe('normalizeSockoptForWire', () => {
|
||||
|
||||
describe('normalizeStreamSettingsForWire reality', () => {
|
||||
it('preserves the nested client settings on inbound (share links read publicKey from there)', () => {
|
||||
const out = normalizeStreamSettingsForWire({
|
||||
network: 'xhttp',
|
||||
security: 'reality',
|
||||
realitySettings: {
|
||||
target: 'play.google.com:443',
|
||||
privateKey: 'priv',
|
||||
serverNames: ['play.google.com'],
|
||||
shortIds: ['abcd'],
|
||||
settings: {
|
||||
publicKey: 'pub',
|
||||
fingerprint: 'chrome',
|
||||
spiderX: '/',
|
||||
const out = normalizeStreamSettingsForWire(
|
||||
{
|
||||
network: 'xhttp',
|
||||
security: 'reality',
|
||||
realitySettings: {
|
||||
target: 'play.google.com:443',
|
||||
privateKey: 'priv',
|
||||
serverNames: ['play.google.com'],
|
||||
shortIds: ['abcd'],
|
||||
settings: {
|
||||
publicKey: 'pub',
|
||||
fingerprint: 'chrome',
|
||||
spiderX: '/',
|
||||
},
|
||||
},
|
||||
},
|
||||
}, { side: 'inbound' });
|
||||
{ side: 'inbound' },
|
||||
);
|
||||
|
||||
const reality = out.realitySettings as Record<string, unknown>;
|
||||
expect(reality.target).toBe('play.google.com:443');
|
||||
@@ -316,17 +351,20 @@ describe('normalizeStreamSettingsForWire reality', () => {
|
||||
});
|
||||
|
||||
it('passes client realitySettings through unchanged on outbound', () => {
|
||||
const out = normalizeStreamSettingsForWire({
|
||||
network: 'xhttp',
|
||||
security: 'reality',
|
||||
realitySettings: {
|
||||
publicKey: 'pub',
|
||||
fingerprint: 'chrome',
|
||||
serverName: 'play.google.com',
|
||||
shortId: 'abcd',
|
||||
spiderX: '/x',
|
||||
const out = normalizeStreamSettingsForWire(
|
||||
{
|
||||
network: 'xhttp',
|
||||
security: 'reality',
|
||||
realitySettings: {
|
||||
publicKey: 'pub',
|
||||
fingerprint: 'chrome',
|
||||
serverName: 'play.google.com',
|
||||
shortId: 'abcd',
|
||||
spiderX: '/x',
|
||||
},
|
||||
},
|
||||
}, { side: 'outbound' });
|
||||
{ side: 'outbound' },
|
||||
);
|
||||
|
||||
const reality = out.realitySettings as Record<string, unknown>;
|
||||
expect(reality.publicKey).toBe('pub');
|
||||
@@ -337,17 +375,20 @@ describe('normalizeStreamSettingsForWire reality', () => {
|
||||
|
||||
describe('normalizeStreamSettingsForWire tls', () => {
|
||||
it('drops empty uTLS fingerprints from inbound and outbound TLS shapes', () => {
|
||||
const out = normalizeStreamSettingsForWire({
|
||||
network: 'hysteria',
|
||||
security: 'tls',
|
||||
tlsSettings: {
|
||||
fingerprint: '',
|
||||
settings: {
|
||||
const out = normalizeStreamSettingsForWire(
|
||||
{
|
||||
network: 'hysteria',
|
||||
security: 'tls',
|
||||
tlsSettings: {
|
||||
fingerprint: '',
|
||||
echConfigList: '',
|
||||
settings: {
|
||||
fingerprint: '',
|
||||
echConfigList: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
}, { side: 'inbound' });
|
||||
{ side: 'inbound' },
|
||||
);
|
||||
|
||||
const tls = out.tlsSettings as Record<string, unknown>;
|
||||
const settings = tls.settings as Record<string, unknown>;
|
||||
@@ -374,7 +415,10 @@ describe('inbound formValuesToWirePayload integration', () => {
|
||||
lastTrafficResetTime: 0,
|
||||
nodeId: null,
|
||||
protocol: 'vless',
|
||||
settings: { clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }], decryption: 'none' },
|
||||
settings: {
|
||||
clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }],
|
||||
decryption: 'none',
|
||||
},
|
||||
streamSettings: {
|
||||
network: 'xhttp',
|
||||
security: 'reality',
|
||||
@@ -478,7 +522,10 @@ describe('inbound formValuesToWirePayload integration', () => {
|
||||
lastTrafficResetTime: 0,
|
||||
nodeId: null,
|
||||
protocol: 'vless',
|
||||
settings: { clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }], decryption: 'none' },
|
||||
settings: {
|
||||
clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }],
|
||||
decryption: 'none',
|
||||
},
|
||||
streamSettings: {
|
||||
network: 'xhttp',
|
||||
security: 'reality',
|
||||
@@ -526,7 +573,10 @@ describe('inbound formValuesToWirePayload integration', () => {
|
||||
lastTrafficResetTime: 0,
|
||||
nodeId: null,
|
||||
protocol: 'vless',
|
||||
settings: { clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }], decryption: 'none' },
|
||||
settings: {
|
||||
clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }],
|
||||
decryption: 'none',
|
||||
},
|
||||
streamSettings: {
|
||||
network: 'xhttp',
|
||||
security: 'reality',
|
||||
|
||||
@@ -3,10 +3,10 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { NetworkSettingsSchema } from '@/schemas/protocols';
|
||||
|
||||
const streamFixtures = import.meta.glob<unknown>(
|
||||
'./golden/fixtures/stream/*.json',
|
||||
{ eager: true, import: 'default' },
|
||||
);
|
||||
const streamFixtures = import.meta.glob<unknown>('./golden/fixtures/stream/*.json', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
});
|
||||
|
||||
function fixtureName(path: string): string {
|
||||
const file = path.split('/').pop() ?? path;
|
||||
@@ -15,7 +15,10 @@ function fixtureName(path: string): string {
|
||||
|
||||
describe('NetworkSettingsSchema fixtures', () => {
|
||||
const entries = Object.entries(streamFixtures).sort(([a], [b]) => a.localeCompare(b));
|
||||
expect(entries.length, 'expected at least one fixture under golden/fixtures/stream').toBeGreaterThan(0);
|
||||
expect(
|
||||
entries.length,
|
||||
'expected at least one fixture under golden/fixtures/stream',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
for (const [path, raw] of entries) {
|
||||
it(`parses ${fixtureName(path)} byte-stably`, () => {
|
||||
@@ -33,7 +36,11 @@ describe('NetworkSettingsSchema method alias', () => {
|
||||
});
|
||||
|
||||
it('prefers method over network when both are present', () => {
|
||||
const parsed = NetworkSettingsSchema.parse({ method: 'grpc', network: 'tcp', grpcSettings: {} });
|
||||
const parsed = NetworkSettingsSchema.parse({
|
||||
method: 'grpc',
|
||||
network: 'tcp',
|
||||
grpcSettings: {},
|
||||
});
|
||||
expect((parsed as { network?: string }).network).toBe('grpc');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,12 @@ import { renderWithProviders } from './test-utils';
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <output data-testid="location">{location.pathname}{location.hash}</output>;
|
||||
return (
|
||||
<output data-testid="location">
|
||||
{location.pathname}
|
||||
{location.hash}
|
||||
</output>
|
||||
);
|
||||
}
|
||||
|
||||
describe('SubscriptionGeneralTab', () => {
|
||||
@@ -17,7 +22,10 @@ describe('SubscriptionGeneralTab', () => {
|
||||
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/settings#subscription']}>
|
||||
<SubscriptionGeneralTab allSetting={new AllSetting({ subPort: 2096 })} updateSetting={updateSetting} />
|
||||
<SubscriptionGeneralTab
|
||||
allSetting={new AllSetting({ subPort: 2096 })}
|
||||
updateSetting={updateSetting}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -34,7 +42,10 @@ describe('SubscriptionGeneralTab', () => {
|
||||
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/settings#subscription']}>
|
||||
<SubscriptionGeneralTab allSetting={new AllSetting({ subPort: 2096 })} updateSetting={updateSetting} />
|
||||
<SubscriptionGeneralTab
|
||||
allSetting={new AllSetting({ subPort: 2096 })}
|
||||
updateSetting={updateSetting}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
|
||||
@@ -37,7 +37,9 @@ function openSelect(select: HTMLElement) {
|
||||
|
||||
function openDropdownOptions(): string[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option'),
|
||||
document.querySelectorAll(
|
||||
'.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option',
|
||||
),
|
||||
)
|
||||
.map((o) => (o.getAttribute('title') ?? o.textContent ?? '').trim())
|
||||
.filter(Boolean);
|
||||
@@ -54,8 +56,9 @@ export function listSelectOptions(fieldId: string): string[] {
|
||||
export function chooseSelectOption(fieldId: string, optionText: string) {
|
||||
const select = selectRootForField(fieldId);
|
||||
openSelect(select);
|
||||
const option = Array.from(document.querySelectorAll('.ant-select-item-option'))
|
||||
.find((o) => (o.getAttribute('title') ?? o.textContent ?? '').trim() === optionText);
|
||||
const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
|
||||
(o) => (o.getAttribute('title') ?? o.textContent ?? '').trim() === optionText,
|
||||
);
|
||||
if (!option) throw new Error(`Option '${optionText}' not found for field '${fieldId}'`);
|
||||
fireEvent.click(option);
|
||||
}
|
||||
|
||||
@@ -30,10 +30,7 @@ describe('useAllSettings', () => {
|
||||
});
|
||||
|
||||
it('keeps an edited setting when a refetch returns older server data', async () => {
|
||||
const values = [
|
||||
{ webPort: 2053 },
|
||||
{ webPort: 2054 },
|
||||
];
|
||||
const values = [{ webPort: 2053 }, { webPort: 2054 }];
|
||||
let index = 0;
|
||||
vi.spyOn(HttpUtil, 'post').mockImplementation(async () => new Msg(true, '', values[index++]));
|
||||
const queryClient = makeTestQueryClient();
|
||||
@@ -56,7 +53,11 @@ describe('useAllSettings', () => {
|
||||
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
|
||||
if (url === '/panel/api/setting/all') {
|
||||
fetchCount += 1;
|
||||
return new Msg(true, '', fetchCount === 1 ? { hasTgBotToken: false } : { hasTgBotToken: true, tgBotToken: '' });
|
||||
return new Msg(
|
||||
true,
|
||||
'',
|
||||
fetchCount === 1 ? { hasTgBotToken: false } : { hasTgBotToken: true, tgBotToken: '' },
|
||||
);
|
||||
}
|
||||
return new Msg(true, '');
|
||||
});
|
||||
@@ -83,7 +84,11 @@ describe('useAllSettings', () => {
|
||||
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
|
||||
if (url === '/panel/api/setting/all') {
|
||||
fetchCount += 1;
|
||||
return new Msg(true, '', fetchCount === 1 ? { hasTgBotToken: false } : { hasTgBotToken: true, tgBotToken: '' });
|
||||
return new Msg(
|
||||
true,
|
||||
'',
|
||||
fetchCount === 1 ? { hasTgBotToken: false } : { hasTgBotToken: true, tgBotToken: '' },
|
||||
);
|
||||
}
|
||||
return new Msg(true, '');
|
||||
});
|
||||
@@ -96,7 +101,11 @@ describe('useAllSettings', () => {
|
||||
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||
act(() => result.current.updateSetting({ tgBotToken: 'secret' }));
|
||||
await act(async () => {
|
||||
await result.current.savePayload({ ...result.current.allSetting, twoFactorEnable: false, twoFactorToken: '' });
|
||||
await result.current.savePayload({
|
||||
...result.current.allSetting,
|
||||
twoFactorEnable: false,
|
||||
twoFactorToken: '',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(3));
|
||||
|
||||
@@ -7,7 +7,12 @@ import { useServerDraft } from '@/hooks/useServerDraft';
|
||||
describe('useServerDraft', () => {
|
||||
it('keeps an edited draft when the server refetches', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
|
||||
({ server }) =>
|
||||
useServerDraft(
|
||||
server,
|
||||
(value) => ({ ...value }),
|
||||
(left, right) => left.value === right.value,
|
||||
),
|
||||
{ initialProps: { server: { value: 'one' } } },
|
||||
);
|
||||
|
||||
@@ -20,7 +25,12 @@ describe('useServerDraft', () => {
|
||||
|
||||
it('accepts a refetch that matches the saved draft', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
|
||||
({ server }) =>
|
||||
useServerDraft(
|
||||
server,
|
||||
(value) => ({ ...value }),
|
||||
(left, right) => left.value === right.value,
|
||||
),
|
||||
{ initialProps: { server: { value: 'one' } } },
|
||||
);
|
||||
|
||||
@@ -33,7 +43,12 @@ describe('useServerDraft', () => {
|
||||
|
||||
it('compares a preserved draft with the latest server value', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
|
||||
({ server }) =>
|
||||
useServerDraft(
|
||||
server,
|
||||
(value) => ({ ...value }),
|
||||
(left, right) => left.value === right.value,
|
||||
),
|
||||
{ initialProps: { server: { value: 'one' } } },
|
||||
);
|
||||
|
||||
@@ -46,7 +61,12 @@ describe('useServerDraft', () => {
|
||||
|
||||
it('hydrates clean drafts under StrictMode', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
|
||||
({ server }) =>
|
||||
useServerDraft(
|
||||
server,
|
||||
(value) => ({ ...value }),
|
||||
(left, right) => left.value === right.value,
|
||||
),
|
||||
{
|
||||
initialProps: { server: { value: 'one' } },
|
||||
wrapper: StrictMode,
|
||||
@@ -61,7 +81,12 @@ describe('useServerDraft', () => {
|
||||
|
||||
it('preserves an edit made before the first server response', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
|
||||
({ server }) =>
|
||||
useServerDraft(
|
||||
server,
|
||||
(value) => ({ ...value }),
|
||||
(left, right) => left.value === right.value,
|
||||
),
|
||||
{ initialProps: { server: undefined as { value: string } | undefined } },
|
||||
);
|
||||
|
||||
@@ -74,7 +99,12 @@ describe('useServerDraft', () => {
|
||||
|
||||
it('marks a sent draft clean before its refetch arrives', () => {
|
||||
const { result } = renderHook(
|
||||
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
|
||||
({ server }) =>
|
||||
useServerDraft(
|
||||
server,
|
||||
(value) => ({ ...value }),
|
||||
(left, right) => left.value === right.value,
|
||||
),
|
||||
{ initialProps: { server: { value: 'one' } } },
|
||||
);
|
||||
|
||||
|
||||
@@ -6,17 +6,49 @@ const x25519Key = 'kO9pIKKPtoUCzo3ZWfWfp0lQoWCyJC1TqL8oz1hpsFM';
|
||||
const mlkem768Key = 'A'.repeat(1590);
|
||||
|
||||
describe('vlessEncryptionAuthKind', () => {
|
||||
const cases: { name: string; encryption: string; want: ReturnType<typeof vlessEncryptionAuthKind> }[] = [
|
||||
const cases: {
|
||||
name: string;
|
||||
encryption: string;
|
||||
want: ReturnType<typeof vlessEncryptionAuthKind>;
|
||||
}[] = [
|
||||
{ name: 'empty string', encryption: '', want: null },
|
||||
{ name: 'none', encryption: 'none', want: null },
|
||||
{ name: 'only dots', encryption: '...', want: null },
|
||||
{ name: 'x25519 native', encryption: `mlkem768x25519plus.native.600s.${x25519Key}`, want: 'x25519' },
|
||||
{ name: 'x25519 xorpub', encryption: `mlkem768x25519plus.xorpub.600s.${x25519Key}`, want: 'x25519_xorpub' },
|
||||
{ name: 'x25519 random', encryption: `mlkem768x25519plus.random.600s.${x25519Key}`, want: 'x25519_random' },
|
||||
{ name: 'mlkem768 native', encryption: `mlkem768x25519plus.native.600s.${mlkem768Key}`, want: 'mlkem768' },
|
||||
{ name: 'mlkem768 xorpub', encryption: `mlkem768x25519plus.xorpub.600s.${mlkem768Key}`, want: 'mlkem768_xorpub' },
|
||||
{ name: 'mlkem768 random', encryption: `mlkem768x25519plus.random.600s.${mlkem768Key}`, want: 'mlkem768_random' },
|
||||
{ name: 'two-segment value treated as native', encryption: `mlkem768x25519plus.${x25519Key}`, want: 'x25519' },
|
||||
{
|
||||
name: 'x25519 native',
|
||||
encryption: `mlkem768x25519plus.native.600s.${x25519Key}`,
|
||||
want: 'x25519',
|
||||
},
|
||||
{
|
||||
name: 'x25519 xorpub',
|
||||
encryption: `mlkem768x25519plus.xorpub.600s.${x25519Key}`,
|
||||
want: 'x25519_xorpub',
|
||||
},
|
||||
{
|
||||
name: 'x25519 random',
|
||||
encryption: `mlkem768x25519plus.random.600s.${x25519Key}`,
|
||||
want: 'x25519_random',
|
||||
},
|
||||
{
|
||||
name: 'mlkem768 native',
|
||||
encryption: `mlkem768x25519plus.native.600s.${mlkem768Key}`,
|
||||
want: 'mlkem768',
|
||||
},
|
||||
{
|
||||
name: 'mlkem768 xorpub',
|
||||
encryption: `mlkem768x25519plus.xorpub.600s.${mlkem768Key}`,
|
||||
want: 'mlkem768_xorpub',
|
||||
},
|
||||
{
|
||||
name: 'mlkem768 random',
|
||||
encryption: `mlkem768x25519plus.random.600s.${mlkem768Key}`,
|
||||
want: 'mlkem768_random',
|
||||
},
|
||||
{
|
||||
name: 'two-segment value treated as native',
|
||||
encryption: `mlkem768x25519plus.${x25519Key}`,
|
||||
want: 'x25519',
|
||||
},
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
|
||||
@@ -4,12 +4,21 @@ import { mergeWarpRotation } from '@/pages/xray/overrides/WarpModal';
|
||||
|
||||
const clientId = btoa(String.fromCharCode(1, 2, 3));
|
||||
|
||||
function rotatedConfig(overrides: { public_key?: string; host?: string; v4?: string; v6?: string } = {}) {
|
||||
function rotatedConfig(
|
||||
overrides: { public_key?: string; host?: string; v4?: string; v6?: string } = {},
|
||||
) {
|
||||
return {
|
||||
config: {
|
||||
client_id: clientId,
|
||||
interface: { addresses: { v4: overrides.v4 ?? '172.16.0.2', v6: overrides.v6 ?? '2606:4700::2' } },
|
||||
peers: [{ public_key: overrides.public_key ?? 'newPub', endpoint: { host: overrides.host ?? 'engage.cloudflareclient.com:2408' } }],
|
||||
interface: {
|
||||
addresses: { v4: overrides.v4 ?? '172.16.0.2', v6: overrides.v6 ?? '2606:4700::2' },
|
||||
},
|
||||
peers: [
|
||||
{
|
||||
public_key: overrides.public_key ?? 'newPub',
|
||||
endpoint: { host: overrides.host ?? 'engage.cloudflareclient.com:2408' },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -42,7 +51,11 @@ describe('mergeWarpRotation', () => {
|
||||
const merged = mergeWarpRotation(
|
||||
existing,
|
||||
{ private_key: 'newSecret' },
|
||||
rotatedConfig({ public_key: 'newPub', host: 'engage.cloudflareclient.com:2408', v4: '172.16.0.9' }),
|
||||
rotatedConfig({
|
||||
public_key: 'newPub',
|
||||
host: 'engage.cloudflareclient.com:2408',
|
||||
v4: '172.16.0.9',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(merged).not.toBeNull();
|
||||
|
||||
@@ -35,7 +35,12 @@ describe('buildWireguardClientConfig', () => {
|
||||
});
|
||||
|
||||
it('uses the inbound DNS override when present', () => {
|
||||
const cfg = buildWireguardClientConfig(client, { ...inbound, wgDns: '9.9.9.9' }, 'example.com', '');
|
||||
const cfg = buildWireguardClientConfig(
|
||||
client,
|
||||
{ ...inbound, wgDns: '9.9.9.9' },
|
||||
'example.com',
|
||||
'',
|
||||
);
|
||||
expect(cfg).toContain('DNS = 9.9.9.9');
|
||||
expect(cfg).not.toContain('DNS = 1.1.1.1, 1.0.0.1');
|
||||
});
|
||||
@@ -49,25 +54,45 @@ describe('buildWireguardClientConfig', () => {
|
||||
});
|
||||
|
||||
it('omits the PresharedKey line when the client has no preshared key', () => {
|
||||
const cfg = buildWireguardClientConfig({ ...client, preSharedKey: undefined }, inbound, 'example.com', '');
|
||||
const cfg = buildWireguardClientConfig(
|
||||
{ ...client, preSharedKey: undefined },
|
||||
inbound,
|
||||
'example.com',
|
||||
'',
|
||||
);
|
||||
expect(cfg).not.toContain('PresharedKey');
|
||||
});
|
||||
|
||||
it('uses the hosting node address as the endpoint host for node-managed inbounds', () => {
|
||||
const cfg = buildWireguardClientConfig(client, { ...inbound, nodeAddress: 'node.example.net' }, 'master.example.com', '');
|
||||
const cfg = buildWireguardClientConfig(
|
||||
client,
|
||||
{ ...inbound, nodeAddress: 'node.example.net' },
|
||||
'master.example.com',
|
||||
'',
|
||||
);
|
||||
expect(cfg).toContain('Endpoint = node.example.net:51820');
|
||||
expect(cfg).not.toContain('master.example.com');
|
||||
});
|
||||
|
||||
it('falls back to the panel host when the node address is blank', () => {
|
||||
const cfg = buildWireguardClientConfig(client, { ...inbound, nodeAddress: ' ' }, 'master.example.com', '');
|
||||
const cfg = buildWireguardClientConfig(
|
||||
client,
|
||||
{ ...inbound, nodeAddress: ' ' },
|
||||
'master.example.com',
|
||||
'',
|
||||
);
|
||||
expect(cfg).toContain('Endpoint = master.example.com:51820');
|
||||
});
|
||||
|
||||
it('honors the custom share-address strategy over the node address', () => {
|
||||
const cfg = buildWireguardClientConfig(
|
||||
client,
|
||||
{ ...inbound, nodeAddress: 'node.example.net', shareAddrStrategy: 'custom', shareAddr: 'vpn.example.com' },
|
||||
{
|
||||
...inbound,
|
||||
nodeAddress: 'node.example.net',
|
||||
shareAddrStrategy: 'custom',
|
||||
shareAddr: 'vpn.example.com',
|
||||
},
|
||||
'master.example.com',
|
||||
'',
|
||||
);
|
||||
@@ -77,7 +102,12 @@ describe('buildWireguardClientConfig', () => {
|
||||
it('honors the listen share-address strategy over the node address', () => {
|
||||
const cfg = buildWireguardClientConfig(
|
||||
client,
|
||||
{ ...inbound, nodeAddress: 'node.example.net', shareAddrStrategy: 'listen', listen: '198.51.100.7' },
|
||||
{
|
||||
...inbound,
|
||||
nodeAddress: 'node.example.net',
|
||||
shareAddrStrategy: 'listen',
|
||||
listen: '198.51.100.7',
|
||||
},
|
||||
'master.example.com',
|
||||
'',
|
||||
);
|
||||
@@ -85,7 +115,12 @@ describe('buildWireguardClientConfig', () => {
|
||||
});
|
||||
|
||||
it('keeps a panel hostname that fails share-host normalization instead of emitting an empty endpoint', () => {
|
||||
const cfg = buildWireguardClientConfig(client, { ...inbound, listen: '0.0.0.0' }, 'wg_gw.corp.lan', '');
|
||||
const cfg = buildWireguardClientConfig(
|
||||
client,
|
||||
{ ...inbound, listen: '0.0.0.0' },
|
||||
'wg_gw.corp.lan',
|
||||
'',
|
||||
);
|
||||
expect(cfg).toContain('Endpoint = wg_gw.corp.lan:51820');
|
||||
expect(cfg).not.toContain('Endpoint = :51820');
|
||||
});
|
||||
|
||||
@@ -78,7 +78,11 @@ describe('wireguard multi-client link/config fan-out', () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
const out = genWireguardLinks({ inbound: legacy, remark: 'wg-legacy', fallbackHostname: 'wg.example.test' });
|
||||
const out = genWireguardLinks({
|
||||
inbound: legacy,
|
||||
remark: 'wg-legacy',
|
||||
fallbackHostname: 'wg.example.test',
|
||||
});
|
||||
const links = out.split('\r\n').filter(Boolean);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]).toContain('address=10.0.0.9%2F32');
|
||||
|
||||
@@ -15,9 +15,9 @@ describe('parseMsg', () => {
|
||||
const msg = new Msg(true, '', { id: 'not-a-number' });
|
||||
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
expect(() => parseMsg(msg, z.object({ id: z.number() }), 'test/value', { strict: true })).toThrow(
|
||||
'test/value response failed validation',
|
||||
);
|
||||
expect(() =>
|
||||
parseMsg(msg, z.object({ id: z.number() }), 'test/value', { strict: true }),
|
||||
).toThrow('test/value response failed validation');
|
||||
expect(warning).toHaveBeenCalledWith(
|
||||
'[zod] test/value response failed validation',
|
||||
expect.arrayContaining([expect.objectContaining({ code: 'invalid_type', path: ['id'] })]),
|
||||
@@ -25,16 +25,20 @@ describe('parseMsg', () => {
|
||||
});
|
||||
|
||||
it('preserves a missing successful payload for callers that handle empty values', () => {
|
||||
expect(parseMsg(new Msg(true, '', null), z.object({ id: z.number() }), 'test/value').obj).toBeNull();
|
||||
expect(
|
||||
parseMsg(new Msg(true, '', null), z.object({ id: z.number() }), 'test/value').obj,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects malformed paged-client payloads', () => {
|
||||
const payload = { items: [], total: 'one', filtered: 1, page: 1, pageSize: 20 };
|
||||
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
expect(() => parseMsg(new Msg(true, '', payload), ClientPageResponseSchema, 'clients/list/paged', { strict: true })).toThrow(
|
||||
'clients/list/paged response failed validation',
|
||||
);
|
||||
expect(() =>
|
||||
parseMsg(new Msg(true, '', payload), ClientPageResponseSchema, 'clients/list/paged', {
|
||||
strict: true,
|
||||
}),
|
||||
).toThrow('clients/list/paged response failed validation');
|
||||
expect(warning).toHaveBeenCalledWith(
|
||||
'[zod] clients/list/paged response failed validation',
|
||||
expect.arrayContaining([expect.objectContaining({ code: 'invalid_type', path: ['total'] })]),
|
||||
@@ -44,13 +48,17 @@ describe('parseMsg', () => {
|
||||
|
||||
describe('fetchXrayConfig', () => {
|
||||
it('keeps a malformed xray payload available for repair', async () => {
|
||||
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', JSON.stringify({ xraySetting: 'not-an-object' })));
|
||||
vi.spyOn(HttpUtil, 'post').mockResolvedValue(
|
||||
new Msg(true, '', JSON.stringify({ xraySetting: 'not-an-object' })),
|
||||
);
|
||||
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
await expect(fetchXrayConfig()).resolves.toEqual({ xraySetting: 'not-an-object' });
|
||||
expect(warning).toHaveBeenCalledWith(
|
||||
'[zod] xray/ config payload failed validation',
|
||||
expect.arrayContaining([expect.objectContaining({ code: 'invalid_type', path: ['xraySetting'] })]),
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'invalid_type', path: ['xraySetting'] }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user