#!/usr/bin/env dart /// E2E 手动测试脚本:对真实后端的完整登录纵切流程(独立脚本,无 Flutter 运行时)。 /// /// 前置条件:patbond-api 目录执行 `docker compose up -d` /// 运行方式:dart run test_e2e_manual.dart /// /// 本脚本直接通过 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; print(' code: ${registerJson['code']}'); if (registerJson['code'] != 0) { print(' ✗ 注册业务错误: ${registerJson['message']}'); exit(1); } final registerData = registerJson['data'] as Map; 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)}...'); print(' refreshToken: ${refreshToken.substring(0, 20)}...'); 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; if (meJson['code'] != 0) { print(' ✗ 获取用户资料业务错误: ${meJson['message']}'); exit(1); } final meData = meJson['data'] as Map; 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; if (refreshJson['code'] != 0) { print(' ✗ 刷新业务错误: ${refreshJson['message']}'); exit(1); } final refreshData = refreshJson['data'] as Map; final newAccessToken = refreshData['accessToken'] as String; final newRefreshToken = refreshData['refreshToken'] as String; print(' ✓ Token 刷新成功'); print(' 新 accessToken: ${newAccessToken.substring(0, 20)}...'); print(' 新 refreshToken: ${newRefreshToken.substring(0, 20)}...'); 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; 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; 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; 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/login(5 次错误密码 → 第 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; 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; 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(); } }