mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-08-06 04:29:52 +00:00
f0d00d6161
* refactor(web): use generated proto network types * fix(core): preserve dumped config flags * test(web): cover config flag save paths * fix(ci): use system protoc before frontend codegen * fix(ci): serialize frontend-lib builds
564 lines
18 KiB
TypeScript
564 lines
18 KiB
TypeScript
import { mount, type VueWrapper } from '@vue/test-utils'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { defineComponent, h, nextTick, reactive } from 'vue'
|
|
import Config from '../src/components/Config.vue'
|
|
import {
|
|
DEFAULT_NETWORK_CONFIG,
|
|
toBackendNetworkConfig,
|
|
type NetworkConfig,
|
|
} from '../src/types/network'
|
|
|
|
const CONFIG_FLAG_FIELDS = [
|
|
'latency_first',
|
|
'use_smoltcp',
|
|
'disable_ipv6',
|
|
'ipv6_public_addr_auto',
|
|
'enable_kcp_proxy',
|
|
'disable_kcp_input',
|
|
'enable_quic_proxy',
|
|
'disable_quic_input',
|
|
'disable_p2p',
|
|
'p2p_only',
|
|
'lazy_p2p',
|
|
'bind_device',
|
|
'no_tun',
|
|
'enable_exit_node',
|
|
'relay_all_peer_rpc',
|
|
'need_p2p',
|
|
'multi_thread',
|
|
'proxy_forward_by_system',
|
|
'disable_encryption',
|
|
'disable_tcp_hole_punching',
|
|
'disable_udp_hole_punching',
|
|
'enable_udp_broadcast_relay',
|
|
'disable_upnp',
|
|
'disable_sym_hole_punching',
|
|
'enable_magic_dns',
|
|
'enable_private_mode',
|
|
] as const satisfies readonly (keyof NetworkConfig)[]
|
|
|
|
const CONFIG_CHECKBOX_FIELDS = [
|
|
['dhcp', '#virtual_ip_auto'],
|
|
...CONFIG_FLAG_FIELDS.map((field) => [field, `#${field}`] as const),
|
|
] as const satisfies readonly (readonly [keyof NetworkConfig, string])[]
|
|
|
|
const CONFIG_TOGGLE_FIELDS = [
|
|
'enable_vpn_portal',
|
|
'enable_relay_network_whitelist',
|
|
'enable_manual_routes',
|
|
'enable_socks5',
|
|
] as const satisfies readonly (keyof NetworkConfig)[]
|
|
|
|
const CONFIG_UI_BOOLEAN_FIELDS = [
|
|
...CONFIG_CHECKBOX_FIELDS.map(([field]) => field),
|
|
...CONFIG_TOGGLE_FIELDS,
|
|
] as const satisfies readonly (keyof NetworkConfig)[]
|
|
|
|
vi.mock('vue-i18n', () => ({
|
|
useI18n: () => ({
|
|
t: (key: string, values?: unknown[]) => values ? `${key}:${values.join(',')}` : key,
|
|
}),
|
|
}))
|
|
|
|
const PassThrough = defineComponent({
|
|
name: 'PassThrough',
|
|
setup(_, { slots }) {
|
|
return () => h('div', slots.default?.())
|
|
},
|
|
})
|
|
|
|
const PanelStub = defineComponent({
|
|
name: 'Panel',
|
|
props: {
|
|
header: String,
|
|
},
|
|
setup(props, { slots }) {
|
|
return () => h('section', { 'data-stub': 'panel', 'data-header': props.header }, slots.default?.())
|
|
},
|
|
})
|
|
|
|
const DividerStub = defineComponent({
|
|
name: 'Divider',
|
|
setup() {
|
|
return () => h('hr', { 'data-stub': 'divider' })
|
|
},
|
|
})
|
|
|
|
function splitList(value: string): string[] {
|
|
return value.split(',').map((item) => item.trim()).filter((item) => item.length > 0)
|
|
}
|
|
|
|
const InputTextStub = defineComponent({
|
|
name: 'InputText',
|
|
props: {
|
|
modelValue: [String, Number],
|
|
id: String,
|
|
disabled: Boolean,
|
|
},
|
|
emits: ['update:modelValue'],
|
|
setup(props, { attrs, emit }) {
|
|
return () => h('input', {
|
|
...attrs,
|
|
id: props.id,
|
|
disabled: props.disabled,
|
|
value: props.modelValue ?? '',
|
|
'data-stub': 'input-text',
|
|
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
|
|
})
|
|
},
|
|
})
|
|
|
|
const PasswordStub = defineComponent({
|
|
name: 'Password',
|
|
props: {
|
|
modelValue: [String, Number],
|
|
id: String,
|
|
disabled: Boolean,
|
|
},
|
|
emits: ['update:modelValue'],
|
|
setup(props, { attrs, emit }) {
|
|
return () => h('input', {
|
|
...attrs,
|
|
id: props.id,
|
|
disabled: props.disabled,
|
|
type: 'password',
|
|
value: props.modelValue ?? '',
|
|
'data-stub': 'password',
|
|
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
|
|
})
|
|
},
|
|
})
|
|
|
|
const InputNumberStub = defineComponent({
|
|
name: 'InputNumber',
|
|
props: {
|
|
modelValue: Number,
|
|
id: String,
|
|
inputId: String,
|
|
disabled: Boolean,
|
|
},
|
|
emits: ['update:modelValue'],
|
|
setup(props, { attrs, emit }) {
|
|
return () => h('input', {
|
|
...attrs,
|
|
id: props.id ?? props.inputId,
|
|
disabled: props.disabled,
|
|
type: 'number',
|
|
value: props.modelValue ?? '',
|
|
'data-stub': 'input-number',
|
|
onInput: (event: Event) => {
|
|
const value = (event.target as HTMLInputElement).value
|
|
emit('update:modelValue', value === '' ? null : Number(value))
|
|
},
|
|
})
|
|
},
|
|
})
|
|
|
|
const CheckboxStub = defineComponent({
|
|
name: 'Checkbox',
|
|
props: {
|
|
modelValue: Boolean,
|
|
inputId: String,
|
|
},
|
|
emits: ['update:modelValue'],
|
|
setup(props, { attrs, emit }) {
|
|
return () => h('input', {
|
|
...attrs,
|
|
id: props.inputId,
|
|
checked: props.modelValue,
|
|
type: 'checkbox',
|
|
'data-stub': 'checkbox',
|
|
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).checked),
|
|
})
|
|
},
|
|
})
|
|
|
|
const ToggleButtonStub = defineComponent({
|
|
name: 'ToggleButton',
|
|
props: {
|
|
modelValue: Boolean,
|
|
onIcon: String,
|
|
offIcon: String,
|
|
onLabel: String,
|
|
offLabel: String,
|
|
},
|
|
emits: ['update:modelValue'],
|
|
setup(props, { emit }) {
|
|
return () => h('button', {
|
|
type: 'button',
|
|
'aria-pressed': String(Boolean(props.modelValue)),
|
|
'data-stub': 'toggle-button',
|
|
onClick: () => emit('update:modelValue', !props.modelValue),
|
|
}, props.modelValue ? props.onLabel : props.offLabel)
|
|
},
|
|
})
|
|
|
|
const AutoCompleteStub = defineComponent({
|
|
name: 'AutoComplete',
|
|
props: {
|
|
modelValue: Array,
|
|
id: String,
|
|
multiple: Boolean,
|
|
},
|
|
emits: ['update:modelValue', 'complete'],
|
|
setup(props, { attrs, emit }) {
|
|
return () => h('input', {
|
|
...attrs,
|
|
id: props.id,
|
|
value: (props.modelValue ?? []).join(','),
|
|
'data-stub': 'auto-complete',
|
|
onInput: (event: Event) => emit('update:modelValue', splitList((event.target as HTMLInputElement).value)),
|
|
})
|
|
},
|
|
})
|
|
|
|
const UrlListInputStub = defineComponent({
|
|
name: 'UrlListInput',
|
|
props: {
|
|
modelValue: Array,
|
|
id: String,
|
|
addLabel: String,
|
|
},
|
|
emits: ['update:modelValue'],
|
|
setup(props, { attrs, emit }) {
|
|
return () => h('input', {
|
|
...attrs,
|
|
id: props.id,
|
|
value: (props.modelValue ?? []).join(','),
|
|
'data-stub': 'url-list-input',
|
|
'data-add-label': props.addLabel,
|
|
onInput: (event: Event) => emit('update:modelValue', splitList((event.target as HTMLInputElement).value)),
|
|
})
|
|
},
|
|
})
|
|
|
|
const SelectButtonStub = defineComponent({
|
|
name: 'SelectButton',
|
|
props: {
|
|
modelValue: String,
|
|
options: Array,
|
|
},
|
|
emits: ['update:modelValue'],
|
|
setup(props, { emit }) {
|
|
return () => h('select', {
|
|
value: props.modelValue,
|
|
'data-stub': 'select-button',
|
|
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLSelectElement).value),
|
|
}, (props.options ?? []).map((option) => h('option', { value: option as string }, option as string)))
|
|
},
|
|
})
|
|
|
|
const ButtonStub = defineComponent({
|
|
name: 'Button',
|
|
props: {
|
|
label: String,
|
|
icon: String,
|
|
disabled: Boolean,
|
|
},
|
|
emits: ['click'],
|
|
setup(props, { slots, emit }) {
|
|
return () => h('button', {
|
|
type: 'button',
|
|
disabled: props.disabled,
|
|
'data-label': props.label ?? props.icon,
|
|
onClick: (event: MouseEvent) => emit('click', event),
|
|
}, slots.default?.() ?? props.label ?? props.icon)
|
|
},
|
|
})
|
|
|
|
const DialogStub = defineComponent({
|
|
name: 'Dialog',
|
|
props: {
|
|
visible: Boolean,
|
|
},
|
|
setup(props, { slots }) {
|
|
return () => h('div', { hidden: !props.visible, 'data-stub': 'dialog' }, [
|
|
slots.default?.(),
|
|
slots.footer?.(),
|
|
])
|
|
},
|
|
})
|
|
|
|
const AclManagerStub = defineComponent({
|
|
name: 'AclManager',
|
|
props: {
|
|
modelValue: Object,
|
|
},
|
|
emits: ['update:modelValue'],
|
|
setup(props) {
|
|
return () => h('pre', { 'data-stub': 'acl-manager' }, JSON.stringify(props.modelValue))
|
|
},
|
|
})
|
|
|
|
function makeConfig(): NetworkConfig {
|
|
const config = DEFAULT_NETWORK_CONFIG()
|
|
|
|
return {
|
|
...config,
|
|
dhcp: false,
|
|
virtual_ipv4: '10.1.2.3',
|
|
network_length: 24,
|
|
network_name: 'mesh-a',
|
|
network_secret: 'secret-a',
|
|
peer_urls: ['tcp://peer-a:11010', 'udp://peer-b:11010'],
|
|
latency_first: true,
|
|
use_smoltcp: true,
|
|
disable_ipv6: true,
|
|
no_tun: true,
|
|
hostname: 'host-a',
|
|
proxy_cidrs: ['10.10.0.0/16', '172.16.1.0/24'],
|
|
enable_vpn_portal: true,
|
|
vpn_portal_client_network_addr: '10.144.0.0',
|
|
vpn_portal_listen_port: 22023,
|
|
listener_urls: ['tcp://0.0.0.0:12010'],
|
|
dev_name: 'tun-test',
|
|
mtu: 1280,
|
|
instance_recv_bps_limit: '9007199254740993',
|
|
enable_relay_network_whitelist: true,
|
|
relay_network_whitelist: ['network-a'],
|
|
enable_manual_routes: true,
|
|
routes: ['192.168.0.0/16'],
|
|
enable_socks5: true,
|
|
socks5_port: 1086,
|
|
exit_nodes: ['exit-a'],
|
|
mapped_listeners: ['tcp://127.0.0.1:22000'],
|
|
port_forwards: [{
|
|
proto: 'udp',
|
|
bind_ip: '0.0.0.0',
|
|
bind_port: 18080,
|
|
dst_ip: '10.0.0.2',
|
|
dst_port: 8080,
|
|
}],
|
|
}
|
|
}
|
|
|
|
function mountConfig(config: NetworkConfig = makeConfig()) {
|
|
const curNetwork = reactive(config) as NetworkConfig
|
|
const wrapper = mount(Config, {
|
|
props: {
|
|
curNetwork,
|
|
hostname: 'host-from-prop',
|
|
},
|
|
global: {
|
|
directives: {
|
|
tooltip: () => {},
|
|
},
|
|
stubs: {
|
|
AclManager: AclManagerStub,
|
|
AutoComplete: AutoCompleteStub,
|
|
Button: ButtonStub,
|
|
Checkbox: CheckboxStub,
|
|
Dialog: DialogStub,
|
|
Divider: DividerStub,
|
|
InputGroup: PassThrough,
|
|
InputGroupAddon: PassThrough,
|
|
InputNumber: InputNumberStub,
|
|
InputText: InputTextStub,
|
|
Panel: PanelStub,
|
|
Password: PasswordStub,
|
|
SelectButton: SelectButtonStub,
|
|
ToggleButton: ToggleButtonStub,
|
|
UrlListInput: UrlListInputStub,
|
|
},
|
|
},
|
|
})
|
|
|
|
return { curNetwork, wrapper }
|
|
}
|
|
|
|
function input(wrapper: VueWrapper, selector: string): HTMLInputElement {
|
|
return wrapper.find(selector).element as HTMLInputElement
|
|
}
|
|
|
|
async function setInput(wrapper: VueWrapper, selector: string, value: string) {
|
|
await wrapper.find(selector).setValue(value)
|
|
await nextTick()
|
|
}
|
|
|
|
describe('Config.vue network config projection', () => {
|
|
it('projects config values into the visible form controls', async () => {
|
|
const { curNetwork, wrapper } = mountConfig()
|
|
await nextTick()
|
|
|
|
expect(input(wrapper, '#network_name').value).toBe('mesh-a')
|
|
expect(input(wrapper, '#network_secret').value).toBe('secret-a')
|
|
expect(input(wrapper, '#virtual_ip').value).toBe('10.1.2.3')
|
|
expect(input(wrapper, '#initial_nodes').value).toBe('tcp://peer-a:11010,udp://peer-b:11010')
|
|
expect(input(wrapper, '#virtual_ip_auto').checked).toBe(false)
|
|
expect(input(wrapper, '#latency_first').checked).toBe(true)
|
|
expect(input(wrapper, '#use_smoltcp').checked).toBe(true)
|
|
expect(input(wrapper, '#disable_ipv6').checked).toBe(true)
|
|
expect(input(wrapper, '#no_tun').checked).toBe(true)
|
|
|
|
expect(input(wrapper, '#hostname').value).toBe('host-a')
|
|
expect(input(wrapper, '#subnet-proxy').value).toBe('10.10.0.0/16,172.16.1.0/24')
|
|
expect(input(wrapper, 'input[placeholder="vpn_portal_client_network"]').value).toBe('10.144.0.0')
|
|
expect(input(wrapper, '#dev_name').value).toBe('tun-test')
|
|
expect(input(wrapper, '#mtu').value).toBe('1280')
|
|
expect(input(wrapper, '#instance_recv_bps_limit').value).toBe('9007199254740993')
|
|
expect(input(wrapper, '#relay_network_whitelist').value).toBe('network-a')
|
|
expect(input(wrapper, '#routes').value).toBe('192.168.0.0/16')
|
|
expect(input(wrapper, '#socks5_port').value).toBe('1086')
|
|
expect(input(wrapper, '#exit_nodes').value).toBe('exit-a')
|
|
expect(input(wrapper, 'input[data-add-label="add_listener_url"]').value).toBe('tcp://0.0.0.0:12010')
|
|
expect(input(wrapper, 'input[data-add-label="add_mapped_listener"]').value).toBe('tcp://127.0.0.1:22000')
|
|
|
|
expect(wrapper.find<HTMLSelectElement>('select[data-stub="select-button"]').element.value).toBe('udp')
|
|
expect(input(wrapper, 'input[placeholder="port_forwards_bind_addr"]').value).toBe('0.0.0.0')
|
|
expect(input(wrapper, 'input[placeholder="port_forwards_dst_addr"]').value).toBe('10.0.0.2')
|
|
expect(wrapper.findComponent(AclManagerStub).props('modelValue')).toStrictEqual(curNetwork.acl)
|
|
})
|
|
|
|
it('projects form edits back into config and backend JSON', async () => {
|
|
const { curNetwork, wrapper } = mountConfig()
|
|
await nextTick()
|
|
|
|
await wrapper.find('#virtual_ip_auto').setValue(false)
|
|
await setInput(wrapper, '#network_name', 'mesh-edited')
|
|
await setInput(wrapper, '#network_secret', 'secret-edited')
|
|
await setInput(wrapper, '#virtual_ip', '10.7.7.7')
|
|
await setInput(wrapper, '#initial_nodes', ' tcp://peer-x:11010, , udp://peer-y:11010 ')
|
|
await wrapper.find('#no_tun').setValue(false)
|
|
await wrapper.find('#disable_ipv6').setValue(false)
|
|
await setInput(wrapper, '#hostname', 'host-edited')
|
|
await setInput(wrapper, '#subnet-proxy', '10.7.0.0/16,172.17.0.0/16')
|
|
await setInput(wrapper, 'input[placeholder="vpn_portal_client_network"]', '10.200.0.0')
|
|
await setInput(wrapper, 'input[data-add-label="add_listener_url"]', 'tcp://0.0.0.0:13010')
|
|
await setInput(wrapper, '#dev_name', 'tun-edited')
|
|
await setInput(wrapper, '#mtu', '1260')
|
|
await setInput(wrapper, '#instance_recv_bps_limit', '9007199254740993')
|
|
await setInput(wrapper, '#relay_network_whitelist', 'network-edited')
|
|
await setInput(wrapper, '#routes', '192.168.10.0/24')
|
|
await setInput(wrapper, '#socks5_port', '1089')
|
|
await setInput(wrapper, '#exit_nodes', 'exit-edited')
|
|
await setInput(wrapper, 'input[data-add-label="add_mapped_listener"]', 'tcp://127.0.0.1:23000')
|
|
await wrapper.find('select[data-stub="select-button"]').setValue('tcp')
|
|
await setInput(wrapper, 'input[placeholder="port_forwards_bind_addr"]', '127.0.0.1')
|
|
await setInput(wrapper, 'input[placeholder="port_forwards_dst_addr"]', '10.9.0.2')
|
|
|
|
const portNumbers = wrapper.findAll<HTMLInputElement>('input#horizontal-buttons')
|
|
await portNumbers[1].setValue('19090')
|
|
await portNumbers[2].setValue('9090')
|
|
|
|
expect(curNetwork).toMatchObject({
|
|
dhcp: false,
|
|
virtual_ipv4: '10.7.7.7',
|
|
network_name: 'mesh-edited',
|
|
network_secret: 'secret-edited',
|
|
peer_urls: ['tcp://peer-x:11010', 'udp://peer-y:11010'],
|
|
no_tun: false,
|
|
disable_ipv6: false,
|
|
hostname: 'host-edited',
|
|
proxy_cidrs: ['10.7.0.0/16', '172.17.0.0/16'],
|
|
vpn_portal_client_network_addr: '10.200.0.0',
|
|
listener_urls: ['tcp://0.0.0.0:13010'],
|
|
dev_name: 'tun-edited',
|
|
mtu: 1260,
|
|
instance_recv_bps_limit: '9007199254740993',
|
|
relay_network_whitelist: ['network-edited'],
|
|
routes: ['192.168.10.0/24'],
|
|
socks5_port: 1089,
|
|
exit_nodes: ['exit-edited'],
|
|
mapped_listeners: ['tcp://127.0.0.1:23000'],
|
|
port_forwards: [{
|
|
proto: 'tcp',
|
|
bind_ip: '127.0.0.1',
|
|
bind_port: 19090,
|
|
dst_ip: '10.9.0.2',
|
|
dst_port: 9090,
|
|
}],
|
|
})
|
|
|
|
const backend = toBackendNetworkConfig(curNetwork)
|
|
expect(backend).toMatchObject({
|
|
virtual_ipv4: '10.7.7.7',
|
|
network_name: 'mesh-edited',
|
|
network_secret: 'secret-edited',
|
|
peer_urls: ['tcp://peer-x:11010', 'udp://peer-y:11010'],
|
|
listener_urls: ['tcp://0.0.0.0:13010'],
|
|
mtu: 1260,
|
|
instance_recv_bps_limit: '9007199254740993',
|
|
port_forwards: [{
|
|
proto: 'tcp',
|
|
bind_ip: '127.0.0.1',
|
|
bind_port: 19090,
|
|
dst_ip: '10.9.0.2',
|
|
dst_port: 9090,
|
|
}],
|
|
})
|
|
})
|
|
|
|
it('round-trips every visible boolean config control into backend JSON', async () => {
|
|
const config = makeConfig()
|
|
const originalFlagValues = new Map(
|
|
CONFIG_UI_BOOLEAN_FIELDS.map((field, index) => {
|
|
const value = index % 2 === 0
|
|
config[field] = value
|
|
return [field, value]
|
|
}),
|
|
)
|
|
|
|
const { curNetwork, wrapper } = mountConfig(config)
|
|
await nextTick()
|
|
|
|
for (const [field, selector] of CONFIG_CHECKBOX_FIELDS) {
|
|
const value = originalFlagValues.get(field)
|
|
expect(input(wrapper, selector).checked, `${field} should project into UI`).toBe(value)
|
|
await wrapper.find(selector).setValue(!value)
|
|
await nextTick()
|
|
}
|
|
|
|
const toggleButtons = wrapper.findAll('button[data-stub="toggle-button"]')
|
|
expect(toggleButtons).toHaveLength(CONFIG_TOGGLE_FIELDS.length)
|
|
for (const [index, field] of CONFIG_TOGGLE_FIELDS.entries()) {
|
|
const value = originalFlagValues.get(field)
|
|
expect(toggleButtons[index].attributes('aria-pressed'), `${field} should project into UI`)
|
|
.toBe(String(value))
|
|
await toggleButtons[index].trigger('click')
|
|
await nextTick()
|
|
}
|
|
|
|
const backend = toBackendNetworkConfig(curNetwork) as Record<string, unknown>
|
|
for (const [field, value] of originalFlagValues) {
|
|
const expectedValue = !value
|
|
expect(curNetwork[field], `${field} should update config`).toBe(expectedValue)
|
|
expect(backend[field], `${field} should be preserved in backend JSON`).toBe(expectedValue)
|
|
}
|
|
})
|
|
|
|
it('keeps uint64 input editable without losing large values', async () => {
|
|
const { curNetwork, wrapper } = mountConfig()
|
|
await nextTick()
|
|
|
|
await setInput(wrapper, '#instance_recv_bps_limit', '1234')
|
|
expect(curNetwork.instance_recv_bps_limit).toBe(1234)
|
|
|
|
await setInput(wrapper, '#instance_recv_bps_limit', 'not-a-number')
|
|
expect(curNetwork.instance_recv_bps_limit).toBe(1234)
|
|
|
|
await setInput(wrapper, '#instance_recv_bps_limit', '0')
|
|
expect(curNetwork.instance_recv_bps_limit).toBeNull()
|
|
expect(input(wrapper, '#instance_recv_bps_limit').value).toBe('')
|
|
|
|
await setInput(wrapper, '#instance_recv_bps_limit', '9007199254740993')
|
|
expect(curNetwork.instance_recv_bps_limit).toBe('9007199254740993')
|
|
|
|
await setInput(wrapper, '#instance_recv_bps_limit', '18446744073709551616')
|
|
expect(curNetwork.instance_recv_bps_limit).toBe('9007199254740993')
|
|
|
|
await setInput(wrapper, '#instance_recv_bps_limit', '')
|
|
expect(curNetwork.instance_recv_bps_limit).toBeNull()
|
|
})
|
|
|
|
it('emits runNetwork with the current projected config', async () => {
|
|
const { curNetwork, wrapper } = mountConfig()
|
|
await nextTick()
|
|
|
|
await setInput(wrapper, '#network_name', 'mesh-running')
|
|
await wrapper.find('button[data-label="run_network"]').trigger('click')
|
|
|
|
expect(wrapper.emitted('runNetwork')?.[0]).toEqual([curNetwork])
|
|
expect((wrapper.emitted('runNetwork')?.[0][0] as NetworkConfig).network_name).toBe('mesh-running')
|
|
})
|
|
})
|