顺序是刻意的。前端一行测试都没有,直接挪代码挪坏了 bun test 不会红,所以先把纯逻辑 抽到能被测试的位置并补上用例,最后才动组件。测试从 37 条增加到 77 条。 抽出来的四块: - shared/outbound-chain.ts 「这个端口现在走哪条线」的链路解析,带防环,9 条测试 - web/delay-test.ts 延迟测试失败时说什么,6 个分支原本一条测试都没有 - web/runtime.ts 运行时状态的模块级单例,照 prefs.ts 已有的模式 - web/components/InboundList.vue 入站列表 出站的文本原本散成四份(读一份、显示一份、写回一份、表单又抄一份),加一种出站模式 要同时改四处,漏一个就是显示与写回不一致。合并成 OUTBOUND_PRESETS 一张表。 给认证和透传白名单补了 20 条测试。两者都是安全边界、都是纯函数、都不需要内核在线, 之前却只靠需要活内核的冒烟脚本间接覆盖。其中「形似但不是健康检查的路径」几条确认了 正则没写松。 另外:配置快照不再把整份文件原文下发(前端从不读,白白让响应体积翻倍); yaml-cosmetics 的测试改走生产路径,之前它自己拼了一遍三步流程,落盘链路漏掉其中一步 测试照样会绿。
182 lines
6.8 KiB
TypeScript
182 lines
6.8 KiB
TypeScript
import { describe, expect, it } from 'bun:test'
|
|
import { readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { openDocument } from '../vendor/config-editor/index.ts'
|
|
import {
|
|
collectInbounds,
|
|
collectOutboundNames,
|
|
fallbackTarget,
|
|
isUnsetOutbound,
|
|
outboundLabel,
|
|
outboundToProxyField,
|
|
OUTBOUND_PRESETS,
|
|
resolveDestination,
|
|
} from './inbounds.ts'
|
|
|
|
const fixture = openDocument(
|
|
readFileSync(join(import.meta.dir, '__fixtures__', 'config-with-comments.yaml'), 'utf8'),
|
|
).data
|
|
|
|
describe('合并两套写法的入站', () => {
|
|
it('只有旧写法时也能列出来,不会是空表', () => {
|
|
const list = collectInbounds(fixture)
|
|
expect(list).toHaveLength(1)
|
|
expect(list[0]!.port).toBe('7777')
|
|
expect(list[0]!.type).toBe('mixed')
|
|
expect(list[0]!.isDefault).toBe(true)
|
|
expect(list[0]!.legacyKey).toBe('mixed-port')
|
|
})
|
|
|
|
it('端口写 0 表示没启用,不该出现在表里', () => {
|
|
const list = collectInbounds({ 'mixed-port': 7777, 'socks-port': 0, port: 0 })
|
|
expect(list.map((i) => i.legacyKey)).toEqual(['mixed-port'])
|
|
})
|
|
|
|
it('新旧两套同时存在时都要列出来', () => {
|
|
const list = collectInbounds({
|
|
'mixed-port': 7777,
|
|
listeners: [{ name: '容器A', type: 'mixed', port: 7778, proxy: 'AUTO' }],
|
|
})
|
|
expect(list.map((i) => i.name)).toEqual(['mixed-port', '容器A'])
|
|
expect(list[0]!.isDefault).toBe(true)
|
|
expect(list[1]!.isDefault).toBe(false)
|
|
expect(list[1]!.listenerIndex).toBe(0)
|
|
})
|
|
|
|
it('默认入站的出站永远是「跟着规则走」,因为旧写法没处写', () => {
|
|
expect(collectInbounds({ 'mixed-port': 7777 })[0]!.outbound).toEqual({ kind: 'rules' })
|
|
})
|
|
})
|
|
|
|
describe('老式端口实际绑在哪', () => {
|
|
// 内核源码 listener/listener.go:709-718。界面上一律显示 0.0.0.0 会骗人。
|
|
it('allow-lan 没开时只绑本机', () => {
|
|
expect(collectInbounds({ 'mixed-port': 7777 })[0]!.listen).toBe('127.0.0.1')
|
|
expect(collectInbounds({ 'mixed-port': 7777, 'allow-lan': false })[0]!.listen).toBe('127.0.0.1')
|
|
})
|
|
|
|
it('allow-lan 开了但没指定地址时绑所有网卡', () => {
|
|
expect(collectInbounds({ 'mixed-port': 7777, 'allow-lan': true })[0]!.listen).toBe('0.0.0.0')
|
|
expect(
|
|
collectInbounds({ 'mixed-port': 7777, 'allow-lan': true, 'bind-address': '*' })[0]!.listen,
|
|
).toBe('0.0.0.0')
|
|
})
|
|
|
|
it('指定了地址就用那个地址', () => {
|
|
expect(
|
|
collectInbounds({ 'mixed-port': 7777, 'allow-lan': true, 'bind-address': '192.168.1.5' })[0]!
|
|
.listen,
|
|
).toBe('192.168.1.5')
|
|
})
|
|
})
|
|
|
|
describe('出站去向的四种形态', () => {
|
|
const of = (proxy?: string) => {
|
|
// 用扩展而不是直接写 proxy: undefined —— 配置里「没有这个键」和「这个键是空」
|
|
// 是两回事,测试要还原真实的文件形状。
|
|
const listener = { name: 'x', type: 'mixed', port: 1, ...(proxy === undefined ? {} : { proxy }) }
|
|
return collectInbounds({ listeners: [listener] })[0]!.outbound
|
|
}
|
|
|
|
it('没写 proxy 就是跟着规则走', () => {
|
|
expect(of()).toEqual({ kind: 'rules' })
|
|
expect(of('')).toEqual({ kind: 'rules' })
|
|
})
|
|
|
|
it('DIRECT 和 GLOBAL 是内核自带的,单独成一类', () => {
|
|
expect(of('DIRECT')).toEqual({ kind: 'direct' })
|
|
expect(of('GLOBAL')).toEqual({ kind: 'global' })
|
|
})
|
|
|
|
it('其余都是指名道姓的出站', () => {
|
|
expect(of('AUTO')).toEqual({ kind: 'named', name: 'AUTO' })
|
|
})
|
|
|
|
it('写回时能还原成原来的字段值', () => {
|
|
expect(outboundToProxyField({ kind: 'rules' })).toBeUndefined()
|
|
expect(outboundToProxyField({ kind: 'direct' })).toBe('DIRECT')
|
|
expect(outboundToProxyField({ kind: 'global' })).toBe('GLOBAL')
|
|
expect(outboundToProxyField({ kind: 'named', name: 'AUTO' })).toBe('AUTO')
|
|
})
|
|
})
|
|
|
|
describe('能被入站引用的出站名字', () => {
|
|
it('列出内置出站、策略组和自有线路', () => {
|
|
const names = collectOutboundNames(fixture)
|
|
expect(names.groups).toEqual(['PROXY', 'AUTO'])
|
|
expect(names.builtins).toContain('DIRECT')
|
|
expect(names.builtins).toContain('GLOBAL')
|
|
})
|
|
|
|
it('订阅带来的线路不在其中——它们不在内核的名字表上', () => {
|
|
const names = collectOutboundNames(fixture)
|
|
// 样本里唯一的订阅是 subs,它带来的线路无法被按名字引用
|
|
expect(names.ownProxies).toEqual([])
|
|
expect([...names.groups, ...names.ownProxies, ...names.builtins]).not.toContain('subs')
|
|
})
|
|
})
|
|
|
|
describe('流量最终去哪', () => {
|
|
const cfg = {
|
|
'mixed-port': 7777,
|
|
rules: ['IP-CIDR,127.0.0.0/8,DIRECT,no-resolve', 'MATCH,PROXY'],
|
|
listeners: [
|
|
{ name: '固定', type: 'mixed', port: 7778, proxy: 'AUTO' },
|
|
{ name: '直连', type: 'mixed', port: 7779, proxy: 'DIRECT' },
|
|
],
|
|
}
|
|
|
|
it('走规则的入站要继续查到规则表末尾的 MATCH', () => {
|
|
// 用户想知道的是流量去哪,不是 proxy 字段写没写
|
|
const legacy = collectInbounds(cfg)[0]!
|
|
expect(legacy.outbound).toEqual({ kind: 'rules' })
|
|
expect(resolveDestination(cfg, legacy)).toBe('PROXY')
|
|
})
|
|
|
|
it('指定了出站的入站就是那个出站', () => {
|
|
const list = collectInbounds(cfg)
|
|
expect(resolveDestination(cfg, list[1]!)).toBe('AUTO')
|
|
expect(resolveDestination(cfg, list[2]!)).toBe('DIRECT')
|
|
})
|
|
|
|
it('MATCH 之后的规则到不了,取第一条', () => {
|
|
expect(fallbackTarget({ rules: ['MATCH,A', 'MATCH,B'] })).toBe('A')
|
|
})
|
|
|
|
it('没有 MATCH 时没有兜底', () => {
|
|
expect(fallbackTarget({ rules: ['MATCH-NOT-REALLY,X'] })).toBeNull()
|
|
expect(fallbackTarget({})).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('出站预设表是读写显示三者的唯一约定', () => {
|
|
it('每种预设的字段值和标签一一对应', () => {
|
|
expect(OUTBOUND_PRESETS.map((p) => [p.mode.kind, p.field, p.label])).toEqual([
|
|
['rules', undefined, 'RULE'],
|
|
['direct', 'DIRECT', 'DIRECT'],
|
|
['global', 'GLOBAL', 'GLOBAL'],
|
|
])
|
|
})
|
|
|
|
it('从配置读出来再写回去,值不变', () => {
|
|
// 这条是防止「显示的」和「写进文件的」跑偏:任何一种模式转一圈都要回到原值
|
|
for (const preset of OUTBOUND_PRESETS) {
|
|
expect(outboundToProxyField(preset.mode)).toBe(preset.field)
|
|
}
|
|
expect(outboundToProxyField({ kind: 'named', name: 'AUTO' })).toBe('AUTO')
|
|
})
|
|
|
|
it('界面上的名字用配置里的原词', () => {
|
|
expect(outboundLabel({ kind: 'rules' })).toBe('RULE')
|
|
expect(outboundLabel({ kind: 'direct' })).toBe('DIRECT')
|
|
expect(outboundLabel({ kind: 'global' })).toBe('GLOBAL')
|
|
expect(outboundLabel({ kind: 'named', name: '固定-Gomami' })).toBe('固定-Gomami')
|
|
})
|
|
|
|
it('只有 RULE 算「没明确指定」,界面据此弱化显示', () => {
|
|
expect(isUnsetOutbound({ kind: 'rules' })).toBe(true)
|
|
expect(isUnsetOutbound({ kind: 'direct' })).toBe(false)
|
|
expect(isUnsetOutbound({ kind: 'named', name: 'AUTO' })).toBe(false)
|
|
})
|
|
})
|