Files
proxy-station/server/src/services/generators/surge-safety.test.ts
YANG JIANKUAN 5326c572a2 feat: 合并边缘节点运维并完善 Surge 配置安全
迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。

完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。

验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
2026-10-03 23:19:59 +08:00

68 lines
5.4 KiB
TypeScript

import { afterAll, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import { clientSupports, nodeInputSchema, type NodeDto } from '@proxy-station/shared';
import { generateSurge, surgeNodeLine } from './surge.js';
import type { ProfileIR } from '../ir.js';
const node: NodeDto = {
...nodeInputSchema.parse({ name: 'JP VMess', protocol: 'vmess', server: 'example.com', port: 443, uuid: '00000000-0000-0000-0000-000000000000', tls: true }),
id: 'fixture', createdAt: 0, updatedAt: 0, extra: {},
} as NodeDto;
const base: ProfileIR = { nodes: [node], groups: [], rules: [{ type: 'FINAL', value: null, policy: 'DIRECT', params: [], ruleset: null }], sections: {} };
const directory = mkdtempSync(path.join(tmpdir(), 'proxy-station-surge-'));
const cli = process.env.SURGE_CLI ?? 'surge-cli';
const nativeAvailable = spawnSync(cli, ['--help'], { timeout: 10000 }).status === 0;
afterAll(() => rmSync(directory, { recursive: true, force: true }));
describe('Surge safe serialization and supported semantics', () => {
it('preserves literals and every filtered group source, including with include-all', () => {
const groups: ProfileIR['groups'] = ['Source A', 'Source B'].map((name) => ({ name, type: 'select', members: [{ kind: 'node', name: node.name }], includeAllNodes: false, filterRegex: null, testUrl: null, interval: null, tolerance: null }));
groups.push({ ...groups[0], name: 'Filtered', includeAllNodes: true, filterRegex: '^JP|SG', members: [{ kind: 'builtin', name: 'DIRECT' }, { kind: 'group', name: 'Source A' }, { kind: 'group', name: 'Source B' }] });
const content = generateSurge({ ...base, groups }, { selfUrl: 'https://example.com/sub/test/surge' }).content;
expect(content).toContain('Filtered = select, DIRECT, include-all-proxies=true, include-other-group="Source A,Source B", policy-regex-filter=^JP|SG');
const all = { ...groups[0], name: 'All', includeAllNodes: true, members: [{ kind: 'builtin' as const, name: 'DIRECT' }] };
expect(generateSurge({ ...base, groups: [all] }, { selfUrl: 'https://example.com/sub/test/surge' }).content).toContain('All = select, DIRECT, "JP VMess"');
});
it('maps TLS and cipher flags and reports unsupported VMess combinations', () => {
expect(surgeNodeLine({ ...node, skipCertVerify: true, security: 'chacha20-poly1305' })).toContain('skip-cert-verify=true, vmess-aead=true, encrypt-method=chacha20-ietf-poly1305');
expect(clientSupports('surge', { ...node, alterId: 64 }).ok).toBe(false);
expect(clientSupports('surge', { ...node, security: 'none' }).ok).toBe(false);
const result = generateSurge({ ...base, nodes: [{ ...node, alterId: 64 }] }, { selfUrl: 'https://example.com/sub/test/surge' });
expect(result.excluded).toHaveLength(1);
expect(result.content).not.toContain('JP VMess =');
});
it('uses one trailing FINAL and omits obsolete group url and fallback tolerance', () => {
const groups: ProfileIR['groups'] = [{ name: 'Test', type: 'fallback', members: [{ kind: 'node', name: node.name }], includeAllNodes: false, filterRegex: null, testUrl: 'https://example.com/test', interval: 300, tolerance: 100 }];
const content = generateSurge({ ...base, groups, rules: [base.rules[0], { type: 'DOMAIN', value: 'example.org', policy: 'DIRECT', params: [], ruleset: null }] }, { selfUrl: 'https://example.com/sub/test/surge' }).content;
expect(content).not.toContain('url=');
expect(content).not.toContain('tolerance=');
expect(content).toContain('DOMAIN,example.org,DIRECT\nFINAL,DIRECT');
expect(generateSurge({ ...base, rules: [] }, { selfUrl: 'https://example.com/sub/test/surge' }).content).toContain('FINAL,DIRECT');
});
it.skipIf(!nativeAvailable)('passes the official local CLI with commas, regex escaping, filtered imports and TLS flags', () => {
const groups: ProfileIR['groups'] = ['A', 'B'].map((name) => ({ name, type: 'select', members: [{ kind: 'node', name: node.name }], includeAllNodes: false, filterRegex: null, testUrl: null, interval: null, tolerance: null }));
groups.push({ ...groups[0], name: 'Filtered', includeAllNodes: true, filterRegex: '^(JP|SG),?', members: [{ kind: 'builtin', name: 'DIRECT' }, { kind: 'group', name: 'A' }, { kind: 'group', name: 'B' }] });
const cases: ProfileIR[] = [
{ ...base, groups },
{ ...base, rules: [{ type: 'URL-REGEX', value: '^https://example.com/(a|b),?c\\d', policy: 'DIRECT', params: [], ruleset: null }, ...base.rules] },
{ ...base, nodes: [{ ...node, protocol: 'trojan', password: 'placeholder,with"quote\\backslash', network: 'ws', wsPath: '/a,b', wsHost: 'example.com' }] },
{ ...base, nodes: [{ ...node, skipCertVerify: true, security: 'chacha20-poly1305' }] },
{ ...base, rules: [{ type: 'AND', value: '((PROTOCOL,UDP),(DOMAIN-SUFFIX,example.com))', policy: 'REJECT-NO-DROP', params: ['notification-text=matched, example'], ruleset: null }, ...base.rules] },
{ ...base, rules: [] },
];
for (const [i, ir] of cases.entries()) {
const file = path.join(directory, `${i}.conf`);
writeFileSync(file, generateSurge(ir, { selfUrl: 'https://example.com/sub/test/surge' }).content, { mode: 0o600 });
const result = spawnSync(cli, ['--check', file], { encoding: 'utf8', timeout: 15000 });
expect(result.stdout + result.stderr).toContain('OK');
expect(result.status).toBe(0);
}
});
});