Files
pangolin/client/test/connect_passthrough_test.dart
wangjia 4dc4127252 feat(client): 三端布局架构 + macOS 桌面端 + app 图标
三端布局(mobile/tablet/desktop):
- core/responsive/form_factor.dart 形态判定 + shell/ 分发器(home_shell→desktop/mobile)
- desktop_shell 对照 ui_kits/desktop/dapp.jsx: 侧栏204·6项 + 套餐卡 + 顶栏(标题/状态/主题切换) + 连接页居中单列
- 新增组件 nav_sidebar / plan_badge_card / content_top_bar / bottom_tab_bar
- 新增一级页 contact_page / settings_page; navigation_provider(NavView)
- 删除旧 widgets/home_shell.dart(逻辑迁入 shell/)

macOS 桌面端:
- 窗口默认 920×600 + 最小 720×560(MainFlutterWindow.swift)
- app 图标替换为穿山甲(AppIcon.appiconset 全套, 由 app-icon.svg 渲染)

其余(本会话):
- Phase2 接线: auth_api/token_store/auth_provider/vpn_bridge_provider + 真实 connection/nodes
- lucide_icons 兼容补丁(packages/lucide_icons_patched) 修复 IconData final 报错
- 测试修复: connect_passthrough(UTF-8) / harness / golden @Skip
- l10n 新增 settingsTitle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 09:23:20 +08:00

150 lines
5.2 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// connect_passthrough_test.dart — M6 透传逐字节断言单测
//
// 验证约束(§3.1):ConnectApi.fetchConfig 返回的字符串,
// 必须与传入 VpnBridge.start 的字符串逐字节相等,Dart 层禁止拼装/修改。
//
// tsk_nuoKSM4Vt-zK
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:pangolin_vpn/services/connect_api.dart';
import 'package:pangolin_vpn/services/vpn_bridge.dart';
// 与 mock server 的 connectConfig 结构一致(四块:inbounds/outbounds/route/dns
const _kMockConfigJson =
'{"log":{"level":"warn","timestamp":true},'
'"inbounds":[{"type":"tun","tag":"tun-in","strict_route":true}],'
'"outbounds":[{"type":"urltest","tag":"auto","outbounds":["reality-out","hy2-out"]}],'
'"route":{"final":"auto"},'
'"dns":{"final":"remote"}}';
void main() {
group('M6 透传约束 — ConnectApi.fetchConfig → VpnBridge.start', () {
test('fetchConfig 结果与 VpnBridge.start 接收的字符串逐字节相等', () async {
// ── Arrange ──
final mockClient = MockClient((request) async {
// 验证请求格式符合 §3.1
expect(request.method, equals('POST'));
expect(request.url.path, contains('/connect'));
expect(request.headers['Authorization'], startsWith('Bearer '));
expect(request.headers['Content-Type'], contains('application/json'));
return http.Response(
_kMockConfigJson,
200,
headers: {'content-type': 'application/json'},
);
});
final api = ConnectApi(
baseUrl: 'http://mock-host',
authToken: 'test-token',
client: mockClient,
);
final bridge = VpnBridge();
// ── Act ──
final fetchedJson = await api.fetchConfig(
nodeId: 'sg-1',
deviceId: 'dev-001',
);
await bridge.start(fetchedJson); // 透传,不修改
// ── Assert:逐字节相等 ──
expect(
bridge.lastConfigJson,
equals(fetchedJson),
reason: 'VpnBridge.start 接收的字符串必须与 fetchConfig 返回的字符串逐字节一致(§3.1 透传约束)',
);
// 双向验证:与原始 HTTP 响应体相等
expect(
bridge.lastConfigJson,
equals(_kMockConfigJson),
reason: 'VpnBridge.start 接收的字符串必须等于 HTTP 响应体原文',
);
});
test('HTTP 401 → ConnectApiException(statusCode=401)', () async {
// 指定 charset=utf-8,否则 http.Response 默认用 Latin-1 编码,
// 遇到中文字符(鉴权失败)会抛 ArgumentError: Contains invalid characters。
final mockClient = MockClient((_) async => http.Response(
'{"code":"unauthorized","message_zh":"鉴权失败","message_en":"Unauthorized"}',
401,
headers: {'content-type': 'application/json; charset=utf-8'},
));
final api = ConnectApi(
baseUrl: 'http://mock-host',
authToken: 'bad-token',
client: mockClient,
);
await expectLater(
api.fetchConfig(nodeId: 'sg-1', deviceId: 'dev-001'),
throwsA(
isA<ConnectApiException>()
.having((e) => e.statusCode, 'statusCode', 401),
),
);
});
test('HTTP 500 → ConnectApiException(statusCode=500)', () async {
final mockClient = MockClient(
(_) async => http.Response('Internal Server Error', 500),
);
final api = ConnectApi(
baseUrl: 'http://mock-host',
authToken: 'token',
client: mockClient,
);
await expectLater(
api.fetchConfig(nodeId: 'sg-1', deviceId: 'dev-001'),
throwsA(isA<ConnectApiException>()),
);
});
test('响应体非 JSON → ConnectApiException(statusCode=-2)', () async {
final mockClient = MockClient(
(_) async => http.Response('not-valid-json', 200),
);
final api = ConnectApi(
baseUrl: 'http://mock-host',
authToken: 'token',
client: mockClient,
);
await expectLater(
api.fetchConfig(nodeId: 'sg-1', deviceId: 'dev-001'),
throwsA(
isA<ConnectApiException>()
.having((e) => e.statusCode, 'statusCode', -2),
),
);
});
test('fetchConfig 不修改响应体(额外字段、空格等)', () async {
// 包含额外空格/换行的 JSON,透传后不应被重新格式化
const rawWithSpaces =
'{"log": {"level": "warn"}, "inbounds": [], "outbounds": [], "route": {}, "dns": {}}';
final mockClient = MockClient(
(_) async => http.Response(rawWithSpaces, 200,
headers: {'content-type': 'application/json'}),
);
final api = ConnectApi(
baseUrl: 'http://mock-host',
authToken: 'token',
client: mockClient,
);
final bridge = VpnBridge();
final fetched = await api.fetchConfig(nodeId: 'x', deviceId: 'y');
await bridge.start(fetched);
expect(
bridge.lastConfigJson,
equals(rawWithSpaces),
reason: '原始响应体的所有空格/格式必须原样保留,不得重新序列化',
);
});
});
}