inbound-form-adapter.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. /// <reference types="vite/client" />
  2. import { describe, expect, it } from 'vitest';
  3. import {
  4. rawInboundToFormValues,
  5. formValuesToWirePayload,
  6. type RawInboundRow,
  7. } from '@/lib/xray/inbound-form-adapter';
  8. import { InboundDbFieldsSchema, InboundFormSchema } from '@/schemas/forms/inbound-form';
  9. import { normalizeXhttpForWire } from '@/lib/xray/stream-wire-normalize';
  10. import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
  11. // Round-trip: raw DB row → InboundFormValues → wire payload, asserting
  12. // that the JSON-stringified settings/streamSettings/sniffing in the
  13. // payload deserialize back to the same data the raw row carried.
  14. interface FixtureCase {
  15. name: string;
  16. row: RawInboundRow;
  17. expectedProtocol: string;
  18. }
  19. const vlessRow: RawInboundRow = {
  20. id: 7,
  21. port: 12345,
  22. listen: '0.0.0.0',
  23. protocol: 'vless',
  24. remark: 'edge-1',
  25. enable: true,
  26. up: 1024,
  27. down: 2048,
  28. total: 1_000_000_000,
  29. expiryTime: 0,
  30. trafficReset: 'monthly',
  31. lastTrafficResetTime: 0,
  32. tag: 'inbound-1',
  33. nodeId: null,
  34. settings: {
  35. clients: [{
  36. id: '8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02',
  37. email: '[email protected]',
  38. flow: '',
  39. limitIp: 0,
  40. totalGB: 0,
  41. expiryTime: 0,
  42. enable: true,
  43. tgId: 0,
  44. subId: 'abc123def',
  45. comment: '',
  46. reset: 0,
  47. }],
  48. decryption: 'none',
  49. encryption: 'none',
  50. fallbacks: [],
  51. },
  52. streamSettings: {
  53. network: 'tcp',
  54. security: 'none',
  55. tcpSettings: { header: { type: 'none' } },
  56. },
  57. sniffing: {
  58. enabled: false,
  59. destOverride: ['http', 'tls', 'quic', 'fakedns'],
  60. metadataOnly: false,
  61. routeOnly: false,
  62. ipsExcluded: [],
  63. domainsExcluded: [],
  64. },
  65. } as RawInboundRow & { id: number };
  66. const cases: FixtureCase[] = [
  67. { name: 'vless tcp none', row: vlessRow, expectedProtocol: 'vless' },
  68. {
  69. name: 'string-coerced settings',
  70. row: {
  71. ...vlessRow,
  72. settings: JSON.stringify(vlessRow.settings),
  73. streamSettings: JSON.stringify(vlessRow.streamSettings),
  74. sniffing: JSON.stringify(vlessRow.sniffing),
  75. },
  76. expectedProtocol: 'vless',
  77. },
  78. {
  79. name: 'empty stream settings drop to undefined',
  80. row: { ...vlessRow, streamSettings: '' },
  81. expectedProtocol: 'vless',
  82. },
  83. {
  84. name: 'unknown trafficReset coerces to never',
  85. row: { ...vlessRow, trafficReset: 'totally-fabricated' },
  86. expectedProtocol: 'vless',
  87. },
  88. ];
  89. describe('rawInboundToFormValues', () => {
  90. for (const { name, row, expectedProtocol } of cases) {
  91. it(`maps ${name}`, () => {
  92. const values = rawInboundToFormValues(row);
  93. expect(values.protocol).toBe(expectedProtocol);
  94. expect(values.port).toBe(row.port);
  95. expect(values.remark).toBe(row.remark ?? '');
  96. if (name === 'unknown trafficReset coerces to never') {
  97. expect(values.trafficReset).toBe('never');
  98. }
  99. if (name === 'empty stream settings drop to undefined') {
  100. expect(values.streamSettings).toBeUndefined();
  101. }
  102. expect(values.shareAddrStrategy).toBe('node');
  103. expect(values.shareAddr).toBe('');
  104. });
  105. }
  106. it('produces values that the InboundFormSchema accepts', () => {
  107. const values = rawInboundToFormValues(vlessRow);
  108. const result = InboundFormSchema.safeParse(values);
  109. expect(result.success).toBe(true);
  110. });
  111. });
  112. // Regression: wireguard (UDP-only) and tunnel (dokodemo-door) have no
  113. // user-selectable transport, so the modal submits streamSettings WITHOUT a
  114. // `network` key — just `security`, plus `sockopt` for tunnel's TProxy. The
  115. // network schema must accept that transportless shape; before the transportless
  116. // union branch landed it failed with "Invalid discriminator value. Expected
  117. // 'tcp' | ..." and blocked every wireguard/tunnel save.
  118. describe('transportless streamSettings (wireguard / tunnel)', () => {
  119. it('accepts wireguard with a network-less streamSettings', () => {
  120. const result = InboundFormSchema.safeParse({
  121. port: 51820,
  122. protocol: 'wireguard',
  123. settings: { secretKey: 'cE9mYWtlLXNlY3JldC1rZXktZm9yLXVuaXQtdGVzdA==', peers: [] },
  124. streamSettings: { security: 'none' },
  125. });
  126. expect(result.success).toBe(true);
  127. });
  128. it('accepts tunnel with sockopt.tproxy and no network', () => {
  129. const result = InboundFormSchema.safeParse({
  130. port: 12345,
  131. protocol: 'tunnel',
  132. settings: { allowedNetwork: 'tcp,udp', followRedirect: true, portMap: {} },
  133. streamSettings: {
  134. security: 'none',
  135. sockopt: SockoptStreamSettingsSchema.parse({ tproxy: 'tproxy' }),
  136. },
  137. });
  138. expect(result.success).toBe(true);
  139. if (result.success) {
  140. const stream = result.data.streamSettings as {
  141. network?: unknown;
  142. sockopt?: { tproxy?: string };
  143. };
  144. expect(stream.network).toBeUndefined();
  145. expect(stream.sockopt?.tproxy).toBe('tproxy');
  146. }
  147. });
  148. it('still rejects a present-but-invalid network value', () => {
  149. const result = InboundFormSchema.safeParse({
  150. port: 12345,
  151. protocol: 'tunnel',
  152. settings: { allowedNetwork: 'tcp,udp', followRedirect: true, portMap: {} },
  153. streamSettings: { network: 'bogus', security: 'none' },
  154. });
  155. expect(result.success).toBe(false);
  156. });
  157. });
  158. describe('formValuesToWirePayload', () => {
  159. it('stringifies settings/streamSettings/sniffing with empty-array/default pruning', () => {
  160. const values = rawInboundToFormValues(vlessRow);
  161. const payload = formValuesToWirePayload(values);
  162. expect(typeof payload.settings).toBe('string');
  163. expect(typeof payload.streamSettings).toBe('string');
  164. expect(typeof payload.sniffing).toBe('string');
  165. // Empty arrays like `fallbacks: []` drop out of the payload to match
  166. // the legacy panel's minimal JSON.
  167. const parsedSettings = JSON.parse(payload.settings);
  168. const { fallbacks: _f, ...expectedSettings } = vlessRow.settings as Record<string, unknown>;
  169. expect(parsedSettings).toEqual(expectedSettings);
  170. expect(JSON.parse(payload.streamSettings)).toEqual(vlessRow.streamSettings);
  171. // Disabled sniffing collapses to the bare `{ enabled: false }`
  172. // regardless of which destOverride/metadataOnly/etc. defaults the
  173. // form carries.
  174. expect(JSON.parse(payload.sniffing)).toEqual({ enabled: false });
  175. });
  176. it('emits empty string for absent streamSettings', () => {
  177. const values = rawInboundToFormValues({ ...vlessRow, streamSettings: '' });
  178. const payload = formValuesToWirePayload(values);
  179. expect(payload.streamSettings).toBe('');
  180. });
  181. it('emits empty sniffing for mtproto (mtg-served, not Xray)', () => {
  182. const values = rawInboundToFormValues({
  183. ...vlessRow,
  184. protocol: 'mtproto',
  185. settings: { fakeTlsDomain: 'www.cloudflare.com', secret: 'ee00' },
  186. });
  187. const payload = formValuesToWirePayload(values);
  188. expect(payload.protocol).toBe('mtproto');
  189. expect(payload.sniffing).toBe('');
  190. });
  191. it('omits nodeId when null', () => {
  192. const values = rawInboundToFormValues({ ...vlessRow, nodeId: null });
  193. const payload = formValuesToWirePayload(values);
  194. expect('nodeId' in payload).toBe(false);
  195. });
  196. it('includes nodeId when set', () => {
  197. const values = rawInboundToFormValues({ ...vlessRow, nodeId: 42 });
  198. const payload = formValuesToWirePayload(values);
  199. expect(payload.nodeId).toBe(42);
  200. });
  201. it('round-trips share address strategy fields', () => {
  202. const values = rawInboundToFormValues({
  203. ...vlessRow,
  204. shareAddrStrategy: 'custom',
  205. shareAddr: 'edge.example.test',
  206. });
  207. const payload = formValuesToWirePayload(values);
  208. expect(payload.shareAddrStrategy).toBe('custom');
  209. expect(payload.shareAddr).toBe('edge.example.test');
  210. });
  211. it('round-trips top-level fields through raw → values → payload → values', () => {
  212. // settings/streamSettings/sniffing don't round-trip byte-equal because
  213. // the wire payload prunes empty arrays and collapses disabled sniffing
  214. // to `{ enabled: false }`. Top-level scalars and the protocol picker
  215. // must still survive the round trip without loss.
  216. const original = rawInboundToFormValues(vlessRow);
  217. const payload = formValuesToWirePayload(original);
  218. const replay = rawInboundToFormValues({
  219. port: payload.port,
  220. listen: payload.listen,
  221. protocol: payload.protocol,
  222. tag: payload.tag,
  223. settings: payload.settings,
  224. streamSettings: payload.streamSettings,
  225. sniffing: payload.sniffing,
  226. up: payload.up,
  227. down: payload.down,
  228. total: payload.total,
  229. remark: payload.remark,
  230. enable: payload.enable,
  231. expiryTime: payload.expiryTime,
  232. trafficReset: payload.trafficReset,
  233. lastTrafficResetTime: payload.lastTrafficResetTime,
  234. nodeId: payload.nodeId ?? null,
  235. });
  236. expect(replay.protocol).toBe(original.protocol);
  237. expect(replay.port).toBe(original.port);
  238. expect(replay.tag).toBe(original.tag);
  239. expect(replay.listen).toBe(original.listen);
  240. expect(replay.up).toBe(original.up);
  241. expect(replay.down).toBe(original.down);
  242. expect(replay.streamSettings).toEqual(original.streamSettings);
  243. });
  244. });
  245. describe('subSortIndex', () => {
  246. it('rawInboundToFormValues defaults to 1 when field is absent', () => {
  247. const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: undefined });
  248. expect(values.subSortIndex).toBe(1);
  249. });
  250. it('rawInboundToFormValues preserves valid values and clamps below-minimum ones to 1', () => {
  251. expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: 5 }).subSortIndex).toBe(5);
  252. expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: 0 }).subSortIndex).toBe(1);
  253. expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: -10 }).subSortIndex).toBe(1);
  254. });
  255. it('formValuesToWirePayload includes subSortIndex in the payload', () => {
  256. const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: 3 });
  257. const payload = formValuesToWirePayload(values);
  258. expect(payload.subSortIndex).toBe(3);
  259. });
  260. it('subSortIndex round-trips through raw → values → payload', () => {
  261. const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: 42 });
  262. const payload = formValuesToWirePayload(values);
  263. const replay = rawInboundToFormValues({ ...vlessRow, subSortIndex: payload.subSortIndex });
  264. expect(replay.subSortIndex).toBe(42);
  265. });
  266. it('InboundDbFieldsSchema enforces an integer minimum of 1 and defaults to 1', () => {
  267. // Reject for the RIGHT reason: the issue must be about subSortIndex, not some
  268. // unrelated field — otherwise a schema that rejects everything would pass.
  269. const nonInt = InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 1.5 });
  270. expect(nonInt.success).toBe(false);
  271. if (!nonInt.success) expect(nonInt.error.issues[0]?.path).toContain('subSortIndex');
  272. const belowMin = InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 0 });
  273. expect(belowMin.success).toBe(false);
  274. if (!belowMin.success) expect(belowMin.error.issues[0]?.path).toContain('subSortIndex');
  275. // A valid integer >= 1 must pass (guards against a mutant rejecting all values).
  276. expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 5 }).success).toBe(true);
  277. expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1);
  278. });
  279. });
  280. describe('legacy xhttp session keys on edit (#5621)', () => {
  281. const legacyXhttpRow: RawInboundRow = {
  282. ...vlessRow,
  283. streamSettings: {
  284. network: 'xhttp',
  285. security: 'none',
  286. xhttpSettings: {
  287. path: '/xh',
  288. mode: 'packet-up',
  289. sessionPlacement: 'cookie',
  290. sessionKey: 'x_session',
  291. },
  292. },
  293. };
  294. it('rawInboundToFormValues lifts sessionPlacement/sessionKey onto the renamed keys', () => {
  295. const values = rawInboundToFormValues(legacyXhttpRow);
  296. const xhttp = (values.streamSettings as unknown as Record<string, Record<string, unknown>>).xhttpSettings;
  297. expect(xhttp.sessionIDPlacement).toBe('cookie');
  298. expect(xhttp.sessionIDKey).toBe('x_session');
  299. expect(xhttp.sessionPlacement).toBeUndefined();
  300. expect(xhttp.sessionKey).toBeUndefined();
  301. expect(xhttp.path).toBe('/xh');
  302. expect(xhttp.xPaddingBytes).toBe('100-1000');
  303. });
  304. it('formValuesToWirePayload never emits the legacy key names', () => {
  305. const values = rawInboundToFormValues(legacyXhttpRow);
  306. const payload = formValuesToWirePayload(values);
  307. const stream = JSON.parse(payload.streamSettings) as Record<string, Record<string, unknown>>;
  308. expect(stream.xhttpSettings.sessionPlacement).toBeUndefined();
  309. expect(stream.xhttpSettings.sessionKey).toBeUndefined();
  310. expect(stream.xhttpSettings.sessionIDPlacement).toBe('cookie');
  311. expect(stream.xhttpSettings.sessionIDKey).toBe('x_session');
  312. });
  313. it('normalizeXhttpForWire lifts stale legacy keys that bypassed the schema', () => {
  314. const out = normalizeXhttpForWire(
  315. { sessionPlacement: 'header', sessionKey: 'x_raw' },
  316. 'inbound',
  317. );
  318. expect(out.sessionIDPlacement).toBe('header');
  319. expect(out.sessionIDKey).toBe('x_raw');
  320. expect(out.sessionPlacement).toBeUndefined();
  321. expect(out.sessionKey).toBeUndefined();
  322. });
  323. });
  324. // Regression (#5864): an inbound saved before xmux.maxConnections got a
  325. // non-zero schema default only ever persisted maxConcurrency. Loading it
  326. // must not resurrect a phantom maxConnections that then fights
  327. // maxConcurrency and deletes it again on the very next save, even if the
  328. // user never touches the XMUX section at all.
  329. describe('xhttp xmux maxConnections stays off when only maxConcurrency was saved (#5864)', () => {
  330. const xmuxRow: RawInboundRow = {
  331. ...vlessRow,
  332. streamSettings: {
  333. network: 'xhttp',
  334. security: 'none',
  335. xhttpSettings: {
  336. path: '/xh',
  337. mode: 'auto',
  338. xmux: { maxConcurrency: '1-2' },
  339. },
  340. },
  341. };
  342. it('rawInboundToFormValues does not default maxConnections to a non-zero value', () => {
  343. const values = rawInboundToFormValues(xmuxRow);
  344. const xhttp = (values.streamSettings as unknown as Record<string, Record<string, unknown>>).xhttpSettings;
  345. expect(xhttp.enableXmux).toBe(true);
  346. const xmux = xhttp.xmux as Record<string, unknown>;
  347. expect(xmux.maxConcurrency).toBe('1-2');
  348. expect(xmux.maxConnections).toBe(0);
  349. });
  350. it('formValuesToWirePayload preserves maxConcurrency on an unedited re-save', () => {
  351. const values = rawInboundToFormValues(xmuxRow);
  352. const payload = formValuesToWirePayload(values);
  353. const stream = JSON.parse(payload.streamSettings) as Record<string, Record<string, unknown>>;
  354. const xmux = stream.xhttpSettings.xmux as Record<string, unknown>;
  355. expect(xmux.maxConcurrency).toBe('1-2');
  356. });
  357. });