// 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() .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()), ); }); 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() .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: '原始响应体的所有空格/格式必须原样保留,不得重新序列化', ); }); }); }