Files
pangolin/client/test/connect_passthrough_test.dart
T
wangjia bd8f974a25 feat(M6): 控制面联调 — mock connect server + 客户端 ConnectApi 透传
tsk_nuoKSM4Vt-zK

## 服务端(server/cmd/mockserver)
- `main.go`(79 行):独立 mock HTTP server,实现 POST /v1/nodes/:id/connect
  - 路径/请求体/响应体字段名与 §3.1 契约逐字一致
  - Bearer token 鉴权(-token 参数,空值跳过,不入库)
  - 缺少 device_id → 400;未授权 → 401;非 POST → 405
  - 返回完整 sing-box config JSON(tun + REALITY/Hy2 outbound + urltest + route/dns)
  - 注:mock 联调;待 #5/#6 真实 connect 接口就绪后替换
- `main_test.go`:6 项单测,覆盖 §3.1 结构校验、auth、method、path

## 客户端(client/)
- `lib/services/connect_api.dart`:ConnectApi 类
  - fetchConfig(nodeId, deviceId) → 原始响应体字符串(不做任何修改)
  - 错误路径:HTTP 非 200 / 超时 / 非法 JSON → ConnectApiException(含双语 message)
- `lib/services/vpn_bridge.dart`:VpnBridge stub(M6 联调,libbox 绑定待 11C)
  - start(configJson) 原样存储,不修改;stop() 清空
- `lib/widgets/home_shell.dart`:_toggle/_pick 替换为真实 API 流程
  - ConnectApi.fetchConfig → VpnBridge.start(透传,无中间变换)
  - 错误路径:ConnectApiException → SnackBar,status 回 off,无残留半开隧道
  - API URL/token/deviceId 由 --dart-define 注入(不入库)
- `lib/widgets/server_tile.dart`:ServerInfo 新增 nodeId 字段
- `pubspec.yaml`:新增 http: ^1.2.1 依赖
- `test/connect_passthrough_test.dart`:4 项透传断言单测
  - 核心:fetchConfig 返回字符串 === VpnBridge.start 接收字符串(逐字节相等)
  - 空格/格式原样保留(不经 jsonEncode 重序列化)
  - HTTP 错误、非法 JSON 错误路径覆盖

## 运行方式(M6 联调)
```
# 启动 mock server(无 auth)
go run ./server/cmd/mockserver -addr :8081

# 启动客户端(指向 mock server)
flutter run \
  --dart-define=PANGOLIN_API_URL=http://localhost:8081 \
  --dart-define=PANGOLIN_API_TOKEN=dev-mock-token \
  --dart-define=PANGOLIN_DEVICE_ID=demo-device-001
```

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:16:29 +08:00

147 lines
5.0 KiB
Dart
Raw 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 {
final mockClient = MockClient((_) async => http.Response(
'{"code":"unauthorized","message_zh":"鉴权失败","message_en":"Unauthorized"}',
401,
));
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: '原始响应体的所有空格/格式必须原样保留,不得重新序列化',
);
});
});
}