// auth_refresh_singleflight_test.dart — refresh token 是「单次使用 + 轮换」, // 并发 401 必须合并为一次刷新,否则旧 JTI 被多次消费 → 误判失效把用户踢下线 // (跨平台「放久了/更新后要重新登录」的根因)。 import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:pangolin_vpn/services/auth_api.dart'; import 'package:pangolin_vpn/services/token_store.dart'; import 'package:pangolin_vpn/state/auth_provider.dart'; class _MemStore implements TokenStore { _MemStore({this.access, this.refresh}); String? access; String? refresh; bool cleared = false; @override Future loadAccessToken() async => access; @override Future loadRefreshToken() async => refresh; @override Future saveTokens({required String access, required String refresh}) async { this.access = access; this.refresh = refresh; } @override Future clear() async { cleared = true; access = null; refresh = null; } @override Future markOnboarded() async {} @override Future isOnboarded() async => true; @override Future saveLastEmail(String email) async {} @override Future loadLastEmail() async => null; } void main() { test('并发刷新 single-flight:多个并发 refresh 只消费一次 refresh token', () async { var hits = 0; final client = MockClient((req) async { if (req.url.path == '/v1/auth/refresh') { hits++; final body = jsonDecode(req.body) as Map; // 模拟服务端单次使用:旧 JTI 一旦被轮换,再用就 401。 if (body['refresh_token'] != 'rt-1') { return http.Response('{"error":"invalid_token"}', 401, headers: {'content-type': 'application/json'}); } return http.Response('{"access_token":"at-2","refresh_token":"rt-2"}', 200, headers: {'content-type': 'application/json'}); } return http.Response('not found', 404); }); final store = _MemStore(access: 'at-1', refresh: 'rt-1'); final n = AuthNotifier(store, api: AuthApi(baseUrl: 'http://test.local', client: client)); await Future.delayed(Duration.zero); // 等构造里的 _loadFromStore final results = await Future.wait([ n.refresh(), n.refresh(), n.refresh(), n.refresh(), n.refresh(), ]); expect(hits, 1, reason: '并发刷新应只打一次 /v1/auth/refresh,旧 token 只消费一次'); expect(results.every((r) => r), isTrue, reason: '所有并发调用都应拿到同一次成功刷新'); expect(store.refresh, 'rt-2', reason: '应轮换到新 refresh token'); expect(store.cleared, isFalse, reason: '不应误触发 logout'); expect(n.state.accessToken, 'at-2'); }); test('刷新后可再次刷新(in-flight 完成后释放)', () async { var current = 'rt-1'; final client = MockClient((req) async { final body = jsonDecode(req.body) as Map; if (body['refresh_token'] != current) { return http.Response('{"error":"invalid_token"}', 401, headers: {'content-type': 'application/json'}); } final next = current == 'rt-1' ? 'rt-2' : 'rt-3'; current = next; return http.Response('{"access_token":"at-$next","refresh_token":"$next"}', 200, headers: {'content-type': 'application/json'}); }); final store = _MemStore(access: 'at-1', refresh: 'rt-1'); final n = AuthNotifier(store, api: AuthApi(baseUrl: 'http://test.local', client: client)); await Future.delayed(Duration.zero); expect(await n.refresh(), isTrue); expect(store.refresh, 'rt-2'); expect(await n.refresh(), isTrue); // in-flight 已释放,用新 token 再刷 expect(store.refresh, 'rt-3'); }); }