Files
patbond-flutter/test_e2e_manual.dart
lixi 3f8388e5d4
CI / flutter-gates (push) Successful in 50s
fix: 清零 flutter analyze 问题并修复隐私红线正则缺陷(CI 门禁)
- 修复真实缺陷:隐私红线 RegExp 使用 Dart 不支持的 (?i) 内联标志,构造即抛异常
  导致带 props 的事件被静默丢弃;改为 caseSensitive: false
- 清理埋点模块未用 import/字段/变量(4 warning)
- 构造函数改用初始化形参(含 Dart 3.12 私有具名参数)
- E2E 手动脚本声明 ignore avoid_print + library 指令
- 门禁:format 0 changed / analyze No issues / flutter test 34 passed
2026-09-04 19:44:46 +08:00

299 lines
11 KiB
Dart
Executable File
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.
#!/usr/bin/env dart
// ignore_for_file: avoid_print — 手动 E2E 脚本,print 即输出。
/// E2E 手动测试脚本:对真实后端的完整登录纵切流程(独立脚本,无 Flutter 运行时)。
///
/// 前置条件:patbond-api 目录执行 `docker compose up -d`
/// 运行方式:dart run test_e2e_manual.dart
library;
///
/// 本脚本直接通过 HTTP 客户端验证契约实现,不依赖 Flutter widget。
import 'dart:convert';
import 'dart:io';
import 'dart:math';
const baseUrl = 'http://127.0.0.1:8081';
const userServiceUrl = 'http://127.0.0.1:8082';
void main() async {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final username = 'e2e_test_$timestamp';
// 随机 11 位手机号(中国手机号格式:+86 + 11 位,这里简化为 13 开头)
final phone = '+8613${Random().nextInt(900000000) + 100000000}';
const password = 'Test@123456';
print('=== Patbond E2E 烟囱测试开始 ===');
print('用户名: $username');
print('手机号: $phone');
print('');
final client = HttpClient();
String? accessToken;
String? refreshToken;
String? userId;
try {
// 1. 注册
print('[1/7] POST /api/v1/auth/register');
final registerReq = await client.postUrl(
Uri.parse('$baseUrl/api/v1/auth/register'),
);
registerReq.headers.contentType = ContentType.json;
registerReq.write(
jsonEncode({'username': username, 'phone': phone, 'password': password}),
);
final registerResp = await registerReq.close();
final registerBody = await utf8.decodeStream(registerResp);
print(' Status: ${registerResp.statusCode}');
if (registerResp.statusCode != 200) {
print(' ✗ 注册失败');
print(' 响应: $registerBody');
exit(1);
}
final registerJson = jsonDecode(registerBody) as Map<String, dynamic>;
print(' code: ${registerJson['code']}');
if (registerJson['code'] != 0) {
print(' ✗ 注册业务错误: ${registerJson['message']}');
exit(1);
}
final registerData = registerJson['data'] as Map<String, dynamic>;
accessToken = registerData['accessToken'] as String;
refreshToken = registerData['refreshToken'] as String;
userId = registerData['userId'] as String;
print(' ✓ 注册成功');
print(' userId: $userId');
print(' accessToken: ${accessToken.substring(0, 20)}...<REDACTED>');
print(' refreshToken: ${refreshToken.substring(0, 20)}...<REDACTED>');
print(' accessTokenExpiresAt: ${registerData['accessTokenExpiresAt']}');
print(
' refreshTokenExpiresAt: ${registerData['refreshTokenExpiresAt']}',
);
print('');
// 2. 获取当前用户 (me)
print('[2/7] GET /api/v1/me');
final meReq = await client.getUrl(Uri.parse('$userServiceUrl/api/v1/me'));
meReq.headers.set('Authorization', 'Bearer $accessToken');
final meResp = await meReq.close();
final meBody = await utf8.decodeStream(meResp);
print(' Status: ${meResp.statusCode}');
if (meResp.statusCode != 200) {
print(' ✗ 获取用户资料失败');
print(' 响应: $meBody');
exit(1);
}
final meJson = jsonDecode(meBody) as Map<String, dynamic>;
if (meJson['code'] != 0) {
print(' ✗ 获取用户资料业务错误: ${meJson['message']}');
exit(1);
}
final meData = meJson['data'] as Map<String, dynamic>;
print(' ✓ 获取用户资料成功');
print(' userId: ${meData['userId']}');
print(' username: ${meData['username']}');
print(' phone: ${meData['phone']}');
print(' createdAt: ${meData['createdAt']}');
print('');
if (meData['username'] != username) {
print(' ✗ 用户名不匹配:期望 $username,实际 ${meData['username']}');
exit(1);
}
// 3. 刷新 token
print('[3/7] POST /api/v1/auth/refresh');
final oldRefreshToken = refreshToken;
final refreshReq = await client.postUrl(
Uri.parse('$baseUrl/api/v1/auth/refresh'),
);
refreshReq.headers.contentType = ContentType.json;
refreshReq.write(jsonEncode({'refreshToken': refreshToken}));
final refreshResp = await refreshReq.close();
final refreshBody = await utf8.decodeStream(refreshResp);
print(' Status: ${refreshResp.statusCode}');
if (refreshResp.statusCode != 200) {
print(' ✗ 刷新失败');
print(' 响应: $refreshBody');
exit(1);
}
final refreshJson = jsonDecode(refreshBody) as Map<String, dynamic>;
if (refreshJson['code'] != 0) {
print(' ✗ 刷新业务错误: ${refreshJson['message']}');
exit(1);
}
final refreshData = refreshJson['data'] as Map<String, dynamic>;
final newAccessToken = refreshData['accessToken'] as String;
final newRefreshToken = refreshData['refreshToken'] as String;
print(' ✓ Token 刷新成功');
print(' 新 accessToken: ${newAccessToken.substring(0, 20)}...<REDACTED>');
print(
' 新 refreshToken: ${newRefreshToken.substring(0, 20)}...<REDACTED>',
);
if (newAccessToken == accessToken) {
print(' ✗ access token 未轮换');
exit(1);
}
if (newRefreshToken == oldRefreshToken) {
print(' ✗ refresh token 未轮换');
exit(1);
}
accessToken = newAccessToken;
refreshToken = newRefreshToken;
print('');
// 4. 验证旧 refresh token 不可用(轮换生效)
print('[4/7] POST /api/v1/auth/refresh(用已轮换的旧 token,应 401');
final oldRefreshReq = await client.postUrl(
Uri.parse('$baseUrl/api/v1/auth/refresh'),
);
oldRefreshReq.headers.contentType = ContentType.json;
oldRefreshReq.write(jsonEncode({'refreshToken': oldRefreshToken}));
final oldRefreshResp = await oldRefreshReq.close();
final oldRefreshBody = await utf8.decodeStream(oldRefreshResp);
print(' Status: ${oldRefreshResp.statusCode}');
if (oldRefreshResp.statusCode == 401) {
final oldRefreshJson = jsonDecode(oldRefreshBody) as Map<String, dynamic>;
print(' ✓ 旧 refresh token 被拒绝(轮换生效)');
print(' code: ${oldRefreshJson['code']}');
print(' message: ${oldRefreshJson['message']}');
} else {
print(' ✗ 旧 refresh token 仍可用(轮换未生效)');
exit(1);
}
print('');
// 5. 退出登录
print('[5/7] POST /api/v1/auth/logout');
final logoutReq = await client.postUrl(
Uri.parse('$baseUrl/api/v1/auth/logout'),
);
logoutReq.headers.contentType = ContentType.json;
logoutReq.headers.set('Authorization', 'Bearer $accessToken');
logoutReq.write(jsonEncode({'refreshToken': refreshToken}));
final logoutResp = await logoutReq.close();
final logoutBody = await utf8.decodeStream(logoutResp);
print(' Status: ${logoutResp.statusCode}');
if (logoutResp.statusCode != 200) {
print(' ✗ 退出失败');
print(' 响应: $logoutBody');
exit(1);
}
final logoutJson = jsonDecode(logoutBody) as Map<String, dynamic>;
if (logoutJson['code'] != 0) {
print(' ✗ 退出业务错误: ${logoutJson['message']}');
exit(1);
}
print(' ✓ 退出成功');
print('');
// 6. 验证退出后 refresh token 不可用
print('[6/7] POST /api/v1/auth/refresh(退出后,应 401');
final postLogoutRefreshReq = await client.postUrl(
Uri.parse('$baseUrl/api/v1/auth/refresh'),
);
postLogoutRefreshReq.headers.contentType = ContentType.json;
postLogoutRefreshReq.write(jsonEncode({'refreshToken': refreshToken}));
final postLogoutRefreshResp = await postLogoutRefreshReq.close();
final postLogoutRefreshBody = await utf8.decodeStream(
postLogoutRefreshResp,
);
print(' Status: ${postLogoutRefreshResp.statusCode}');
if (postLogoutRefreshResp.statusCode == 401) {
final postLogoutJson =
jsonDecode(postLogoutRefreshBody) as Map<String, dynamic>;
print(' ✓ 退出后 refresh token 已失效');
print(' code: ${postLogoutJson['code']}');
print(' message: ${postLogoutJson['message']}');
} else {
print(' ✗ 退出后 refresh token 仍可用');
exit(1);
}
print('');
// 7. 登录失败锁定测试
// 锁定语义(后端实现 + 契约一致):窗口内累计 5 次失败即记录锁定;
// 第 5 次失败本身仍返回 40100,其后的任何尝试(含正确密码)返回 423/42300。
print('[7/7] POST /api/v1/auth/login5 次错误密码 → 第 6 次触发 423/42300');
for (int i = 1; i <= 5; i++) {
print(' 错误密码尝试 $i/5...');
final badLoginReq = await client.postUrl(
Uri.parse('$baseUrl/api/v1/auth/login'),
);
badLoginReq.headers.contentType = ContentType.json;
badLoginReq.write(
jsonEncode({'username': username, 'password': 'WrongPassword$i'}),
);
final badLoginResp = await badLoginReq.close();
final badLoginBody = await utf8.decodeStream(badLoginResp);
final badLoginJson = jsonDecode(badLoginBody) as Map<String, dynamic>;
print(
' → HTTP ${badLoginResp.statusCode} / code ${badLoginJson['code']}: '
'${badLoginJson['message']}',
);
if (badLoginResp.statusCode == 423) {
// 之前的失败计数已触发锁定(本脚本重复运行时会出现),也算验证通过
print(' ✓ 账号已处于锁定状态(423/42300');
break;
}
await Future.delayed(const Duration(milliseconds: 300));
}
// 第 6 次:用正确密码验证「锁定期间即使密码正确也返回 423/42300」
print(' 第 6 次尝试(正确密码,应因锁定被拒绝)...');
final lockedLoginReq = await client.postUrl(
Uri.parse('$baseUrl/api/v1/auth/login'),
);
lockedLoginReq.headers.contentType = ContentType.json;
lockedLoginReq.write(
jsonEncode({'username': username, 'password': password}),
);
final lockedLoginResp = await lockedLoginReq.close();
final lockedLoginBody = await utf8.decodeStream(lockedLoginResp);
print(' Status: ${lockedLoginResp.statusCode}');
if (lockedLoginResp.statusCode == 423) {
final lockedJson = jsonDecode(lockedLoginBody) as Map<String, dynamic>;
if (lockedJson['code'] == 42300) {
print(' ✓ 锁定生效:正确密码也被拒绝(423/42300');
print(' message: ${lockedJson['message']}');
} else {
print(' ✗ 状态码正确但业务码错误: ${lockedJson['code']}');
exit(1);
}
} else {
print(' ✗ 锁定未生效,HTTP ${lockedLoginResp.statusCode}');
print(' 响应: $lockedLoginBody');
exit(1);
}
print('');
print('=== E2E 烟囱测试全部通过 ✓ ===');
} catch (e, stack) {
print('✗ 测试异常: $e');
print(stack);
exit(1);
} finally {
client.close();
}
}