feat(flutter): 앱 로그인 · 토큰 갱신 · 모듈 라우터 기반을 만든다

앱 1단계의 바탕이다(계약 1절 "앱 로그인" — 서버는 다음 차례, 지금은 가짜 서버로 시험한다).

- 토큰: 리프레시 토큰은 보안 저장소에만, 액세스 토큰은 메모리에만. 첫 실행 표시가 없으면 남은 토큰을
  지우고, 안드로이드 백업을 끈다. 보안 저장소 일은 차례로 한다
- 인터셉터: 401 이면 갱신 한 번(동시 401 묶음) 뒤 다시 보내기, AUTH_010 은 me 를 다시 받아 그 단계로,
  미리 갱신 30초 전 · 만료는 응답 Date 로, 세션 세대가 바뀐 뒤의 응답은 조용히 버린다,
  비밀이 든 요청의 실패 예외는 본문 · Bearer 를 지운다
- 세션: 로그인 · 코드 · 새 비밀번호 · 2단계 켜기 단계의 상태와 늦은 결과 버리기(버린 로그인의 토큰은
  조용히 logout), 로그아웃은 메모리부터 비운다, 유예 응답이면 세션 끝 + 조용한 logout
- 모듈 틀: go_router StatefulShellRoute 와 아래 탭 막대 — 모듈 목록은 웹 app-modules.ts 와 짝,
  첫 판은 [메일 · 설정]. 모듈끼리 import 하지 않는다
- Riverpod 자동 다시 하기를 끈다, Pretendard 1.3.9(OFL), 확정 글자 크기, 시험 126건
- 판은 Flutter 3.41.9(Dart 3.11.5) — pubspec 의 sdk 하한과 lock 을 함께

리뷰 세 번 반영(AUTH_010 다시 보내기 · 늦은 응답 · 저장 실패 · 로그아웃 차례 · 비밀이 실린 객체 등).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 11:11:28 +09:00
parent 5823f600aa
commit 35fcf5e4ef
82 changed files with 7339 additions and 324 deletions
+271
View File
@@ -0,0 +1,271 @@
import 'package:arasystem_app/app.dart';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/api/error_lexicon.dart';
import 'package:arasystem_app/core/api/error_notifier.dart';
import 'package:arasystem_app/core/constants/app_constants.dart';
import 'package:arasystem_app/core/constants/screen_titles.dart';
import 'package:arasystem_app/core/router/app_router.dart';
import 'package:arasystem_app/core/utils/provider_retry.dart';
import 'package:arasystem_app/features/mail/mail_routes.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'support/fakes.dart';
import 'support/provider_harness.dart';
void main() {
late ProviderHarness h;
setUp(() => h = ProviderHarness());
Future<void> pumpApp(WidgetTester tester) async {
await tester.pumpWidget(
ProviderScope(
retry: noAutomaticRetry,
overrides: h.overrides,
child: const App(),
),
);
await tester.pumpAndSettle();
}
group('앱 뿌리 · 라우터', () {
testWidgets('세션이 없으면 로그인 화면으로 간다 — 한국어 · 앱 이름', (
WidgetTester tester,
) async {
await h.prepare();
await pumpApp(tester);
expect(find.text(ScreenTitles.login), findsOneWidget);
final MaterialApp app = tester.widget(find.byType(MaterialApp));
expect(app.title, kAppName);
expect(app.locale, kAppLocale);
final BuildContext context = tester.element(
find.text(ScreenTitles.login),
);
expect(Localizations.localeOf(context).languageCode, 'ko');
});
testWidgets('되살린 세션에 새 비밀번호 제한이 있으면 그 단계 화면이다', (
WidgetTester tester,
) async {
await h.prepare(refreshToken: 'refresh-1');
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.ok(<String, Object?>{
'tokens': tokensJson(2, now: h.clock.now),
}),
);
h.server.on(
'GET',
ApiPaths.me,
(_) => FakeResponse.ok(sessionJson(restriction: 'passwordChange')),
);
await pumpApp(tester);
expect(find.text(ScreenTitles.passwordChange), findsOneWidget);
});
testWidgets('되살린 세션에 제한이 없으면 메일 화면이다', (WidgetTester tester) async {
await h.prepare(refreshToken: 'refresh-1');
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.ok(<String, Object?>{
'tokens': tokensJson(2, now: h.clock.now),
}),
);
h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()));
await pumpApp(tester);
expect(
find.descendant(
of: find.byType(ListView),
matching: find.text(MailTexts.home),
),
findsOneWidget,
);
expect(find.byType(NavigationBar), findsOneWidget);
});
testWidgets('되살리기가 장애로 실패하면 시작 화면에 남는다', (WidgetTester tester) async {
await h.prepare(refreshToken: 'refresh-1');
h.server.on('POST', ApiPaths.appRefresh, (_) => FakeResponse.offline);
await pumpApp(tester);
expect(find.text(ScreenTitles.start), findsOneWidget);
});
});
group('모듈 · 탭 막대', () {
Future<GoRouter> pumpSignedIn(WidgetTester tester) async {
await h.prepare(refreshToken: 'refresh-1');
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.ok(<String, Object?>{
'tokens': tokensJson(2, now: h.clock.now),
}),
);
h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()));
await pumpApp(tester);
return ProviderScope.containerOf(
tester.element(find.byType(MaterialApp)),
).read(routerProvider);
}
Finder tab(String label) => find.descendant(
of: find.byType(NavigationBar),
matching: find.text(label),
);
Finder page(String title) =>
find.descendant(of: find.byType(ListView), matching: find.text(title));
testWidgets('켜진 모듈만 탭이 된다 — 메일 · 설정 차례', (WidgetTester tester) async {
await pumpSignedIn(tester);
final NavigationBar bar = tester.widget(find.byType(NavigationBar));
expect(bar.destinations, hasLength(2));
expect(tab('메일'), findsOneWidget);
expect(tab('설정'), findsOneWidget);
expect(tab('캘린더'), findsNothing);
expect(tab('주소록'), findsNothing);
expect(
tester.getTopLeft(tab('메일')).dx,
lessThan(tester.getTopLeft(tab('설정')).dx),
);
});
testWidgets('탭을 바꿨다 돌아와도 메일 탭의 이동 기록이 남는다', (WidgetTester tester) async {
final GoRouter router = await pumpSignedIn(tester);
router.go(MailRoutes.mailbox('archive'));
await tester.pumpAndSettle();
expect(page(MailTexts.mailbox), findsOneWidget);
await tester.tap(tab('설정'));
await tester.pumpAndSettle();
expect(page(SettingsTexts.home), findsOneWidget);
await tester.tap(tab('메일'));
await tester.pumpAndSettle();
expect(page(MailTexts.mailbox), findsOneWidget);
expect(
router.routerDelegate.currentConfiguration.uri.path,
MailRoutes.mailbox('archive'),
);
// 지금 탭을 다시 누르면 그 모듈의 첫 화면으로
await tester.tap(tab('메일'));
await tester.pumpAndSettle();
expect(page(MailTexts.home), findsOneWidget);
});
testWidgets('읽기 · 쓰기는 탭 막대 없이 전체 화면으로 연다', (WidgetTester tester) async {
final GoRouter router = await pumpSignedIn(tester);
router.go(MailRoutes.message('m1'));
await tester.pumpAndSettle();
expect(page(MailTexts.message), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
});
testWidgets('알 수 없는 모듈 · 꺼진 모듈 · 없는 주소는 메일 첫 화면으로 간다', (
WidgetTester tester,
) async {
final GoRouter router = await pumpSignedIn(tester);
for (final String location in <String>[
'/calendar',
'/contacts/c1',
'/bogus',
'/mail/nowhere/at-all',
]) {
router.go(MailRoutes.mailbox('archive'));
await tester.pumpAndSettle();
router.go(location);
await tester.pumpAndSettle();
expect(
router.routerDelegate.currentConfiguration.uri.path,
MailRoutes.home,
reason: location,
);
expect(page(MailTexts.home), findsOneWidget, reason: location);
}
});
testWidgets('설정 첫 화면의 절과 [로그아웃] — 누르면 로그인 화면으로', (
WidgetTester tester,
) async {
final GoRouter router = await pumpSignedIn(tester);
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
await tester.tap(tab('설정'));
await tester.pumpAndSettle();
for (final String item in <String>[
SettingsTexts.account,
SettingsTexts.notifications,
SettingsTexts.about,
SettingsTexts.support,
SettingsTexts.signOut,
]) {
expect(page(item), findsOneWidget, reason: item);
}
await tester.tap(page(SettingsTexts.signOut));
await tester.pumpAndSettle();
expect(find.text(ScreenTitles.login), findsOneWidget);
expect(router.routerDelegate.currentConfiguration.uri.path, '/login');
expect(h.server.received('POST', ApiPaths.appLogout), hasLength(1));
});
});
group('전역 알림', () {
testWidgets('API 실패는 전역 ScaffoldMessenger 의 스낵바로 사전 문구를 보인다', (
WidgetTester tester,
) async {
final GlobalKey<ScaffoldMessengerState> key =
GlobalKey<ScaffoldMessengerState>();
final ErrorNotifier notifier = ErrorNotifier(messengerKey: key);
await tester.pumpWidget(
MaterialApp(
scaffoldMessengerKey: key,
home: const Scaffold(body: SizedBox.shrink()),
),
);
notifier.show(ApiException.fromCode('MAIL_001'));
await tester.pump();
expect(find.text(ErrorLexicon.getMessage('MAIL_001')), findsOneWidget);
});
testWidgets('앱의 알림 자리가 MaterialApp 에 걸려 있다', (WidgetTester tester) async {
await h.prepare();
await pumpApp(tester);
final MaterialApp app = tester.widget(find.byType(MaterialApp));
final ProviderContainer container = ProviderScope.containerOf(
tester.element(find.byType(MaterialApp)),
);
expect(
app.scaffoldMessengerKey,
container.read(scaffoldMessengerKeyProvider),
);
});
});
}
@@ -0,0 +1,974 @@
import 'dart:async';
import 'dart:convert';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/api/request_flags.dart';
import 'package:arasystem_app/core/api/session_events.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/fakes.dart';
/// 보호 API 하나 — 시험용
const String _mailState = '/mail-state';
void main() {
late ApiHarness api;
setUp(() async {
api = ApiHarness();
await api.prepare();
});
/// 지금 액세스 토큰이면 200, 아니면 401 인 보호 API
void protectedRoute({required String validToken}) {
api.server.on('GET', _mailState, (RequestOptions options) {
return bearerTokenOf(options) == validToken
? FakeResponse.ok(<String, Object?>{'state': 's1'})
: FakeResponse.fail(401, 'AUTH_001');
});
}
/// 갱신할 때마다 다음 토큰(2 · 3 · …)을 주는 서버 — 서버 시각은 [serverNow] 로 센다
void refreshIssuesNext({DateTime Function()? serverNow}) {
int next = 2;
api.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.ok(<String, Object?>{
...sessionJson(),
'tokens': tokensJson(next++, now: serverNow?.call() ?? api.clock.now),
}),
);
}
int refreshCount() => api.server.received('POST', ApiPaths.appRefresh).length;
group('토큰 붙이기 · 미리 갱신', () {
test('로그인한 요청에 Bearer 와 앱 User-Agent 를 싣고 응답을 푼다', () async {
await api.signIn();
protectedRoute(validToken: 'access-1');
final Response<Map<String, Object?>> response = await api.dio
.get<Map<String, Object?>>(_mailState);
expect(response.data, <String, Object?>{'state': 's1'});
final RequestOptions sent = api.server.requests.single;
expect(bearerTokenOf(sent), 'access-1');
expect(sent.headers['User-Agent'], kTestUserAgent);
});
test('앱을 새로 띄워 액세스 토큰이 없으면 보내기 전에 한 번 갱신한다(401 을 보지 않는다)', () async {
await api.coldStartWithRefreshToken('refresh-1');
refreshIssuesNext();
protectedRoute(validToken: 'access-2');
await api.dio.get<Object?>(_mailState);
expect(refreshCount(), 1);
expect(
api.server.received('POST', ApiPaths.appRefresh).single.data,
<String, Object?>{'refreshToken': 'refresh-1'},
);
expect(api.server.received('GET', _mailState), hasLength(1));
expect(api.secure.values[kRefreshTokenKey], 'refresh-2');
expect(api.emitted.whereType<SessionRefreshed>(), hasLength(1));
});
test('액세스 토큰이 곧 끝나면(30초 안) 미리 갱신한다', () async {
await api.signIn();
api.clock.advance(const Duration(minutes: 59, seconds: 40));
refreshIssuesNext();
protectedRoute(validToken: 'access-2');
await api.dio.get<Object?>(_mailState);
expect(refreshCount(), 1);
expect(
bearerTokenOf(api.server.received('GET', _mailState).single),
'access-2',
);
});
test('기기 시계가 서버보다 2시간 빨라도 응답의 Date 로 남은 시간을 재 요청마다 갱신하지 않는다', () async {
final DateTime serverNow = api.clock.now.subtract(
const Duration(hours: 2),
);
api.server.serverTime = serverNow;
await api.coldStartWithRefreshToken('refresh-1');
refreshIssuesNext(serverNow: () => serverNow);
api.server.on(
'GET',
_mailState,
(_) => FakeResponse.ok(<String, Object?>{'state': 's1'}),
);
await api.dio.get<Object?>(_mailState);
await api.dio.get<Object?>(_mailState);
expect(refreshCount(), 1);
// 받은 때부터 59분 31초 — 만료 30초 전이라 다시 갱신한다
api.clock.advance(const Duration(minutes: 59, seconds: 31));
await api.dio.get<Object?>(_mailState);
expect(refreshCount(), 2);
});
test('미리 갱신이 거절되면 원래 요청을 보내지 않고 AUTH_002 로 한 번 알린다', () async {
await api.coldStartWithRefreshToken('refresh-1');
api.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.fail(401, 'AUTH_001'),
);
protectedRoute(validToken: 'access-2');
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'AUTH_002');
expect(api.server.received('GET', _mailState), isEmpty);
expect(api.notifier.shownCodes, <String>['AUTH_002']);
expect(api.emitted.whereType<SessionEnded>(), hasLength(1));
expect(api.store.hasSession, isFalse);
});
test('미리 갱신이 장애면 원래 요청을 보내지 않고 그 코드로 알리며 세션은 둔다', () async {
await api.coldStartWithRefreshToken('refresh-1');
api.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.fail(503, 'MAIL_001'),
);
protectedRoute(validToken: 'access-2');
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'MAIL_001');
expect(api.server.received('GET', _mailState), isEmpty);
expect(api.notifier.shownCodes, <String>['MAIL_001']);
expect(api.store.hasSession, isTrue);
});
test('연결이 끊긴 동안 줄 선 요청들은 갱신을 한 번만 보내고, 5초가 지나면 다시 묻는다', () async {
await api.coldStartWithRefreshToken('refresh-1');
api.server.on('POST', ApiPaths.appRefresh, (_) => FakeResponse.offline);
protectedRoute(validToken: 'access-2');
final List<Object> errors = await Future.wait(<Future<Object>>[
errorOf(api.dio.get<Object?>(_mailState)),
errorOf(api.dio.get<Object?>(_mailState)),
errorOf(api.dio.get<Object?>(_mailState)),
]);
expect(errors.map((Object e) => apiErrorOf(e).code), <String>[
'SYS_001',
'SYS_001',
'SYS_001',
]);
expect(refreshCount(), 1);
expect(api.notifier.shownCodes, <String>['SYS_001']);
api.clock.advance(const Duration(seconds: 5));
await errorOf(api.dio.get<Object?>(_mailState));
expect(refreshCount(), 2);
});
});
group('401 → 갱신 → 다시', () {
test('401 이면 갱신을 한 번 하고 원래 요청을 새 토큰으로 한 번 다시 보낸다', () async {
await api.signIn();
refreshIssuesNext();
protectedRoute(validToken: 'access-2');
final Response<Map<String, Object?>> response = await api.dio
.get<Map<String, Object?>>(_mailState);
expect(response.data, <String, Object?>{'state': 's1'});
final List<RequestOptions> calls = api.server.received('GET', _mailState);
expect(calls.map(bearerTokenOf), <String?>['access-1', 'access-2']);
expect(refreshCount(), 1);
expect(api.store.accessToken, 'access-2');
expect(api.secure.values[kRefreshTokenKey], 'refresh-2');
expect(api.notifier.shownCodes, isEmpty);
});
test('동시에 401 을 받은 요청 셋은 갱신 한 번에 묶이고 모두 성공한다', () async {
await api.signIn();
final Completer<void> allRejected = Completer<void>();
int rejected = 0;
api.server.on('GET', _mailState, (RequestOptions options) {
if (bearerTokenOf(options) == 'access-2') {
return FakeResponse.ok(<String, Object?>{'state': 's1'});
}
rejected++;
if (rejected == 3) allRejected.complete();
return FakeResponse.fail(401, 'AUTH_001');
});
api.server.on('POST', ApiPaths.appRefresh, (_) async {
// 세 요청이 모두 401 을 받은 뒤에야 갱신이 끝난다
await allRejected.future;
return FakeResponse.ok(<String, Object?>{'tokens': tokensJson(2)});
});
final List<Response<Object?>> responses =
await Future.wait(<Future<Response<Object?>>>[
api.dio.get<Object?>(_mailState),
api.dio.get<Object?>(_mailState),
api.dio.get<Object?>(_mailState),
]);
expect(responses.map((Response<Object?> r) => r.statusCode), <int?>[
200,
200,
200,
]);
expect(refreshCount(), 1);
expect(api.server.received('GET', _mailState), hasLength(6));
});
test('FormData 본문은 다시 보낼 때 새로 만든다(한 번 보낸 FormData 는 다시 쓸 수 없다)', () async {
await api.signIn();
refreshIssuesNext();
api.server.on('POST', '/mail-attachments', (RequestOptions options) {
return bearerTokenOf(options) == 'access-2'
? FakeResponse.ok(<String, Object?>{'id': 'a1'})
: FakeResponse.fail(401, 'AUTH_001');
});
final Response<Map<String, Object?>> response = await api.dio
.post<Map<String, Object?>>(
'/mail-attachments',
data: FormData.fromMap(<String, Object?>{
'note': '첨부',
'file': MultipartFile.fromString('hello', filename: 'a.txt'),
}),
);
expect(response.data, <String, Object?>{'id': 'a1'});
final List<RequestOptions> sent = api.server.received(
'POST',
'/mail-attachments',
);
expect(sent, hasLength(2));
final FormData first = sent.first.data! as FormData;
final FormData second = sent.last.data! as FormData;
expect(identical(first, second), isFalse);
expect(second.fields, first.fields);
expect(second.files.single.value.filename, 'a.txt');
});
test('갱신이 401 이면 토큰을 지우고 AUTH_002 로 알리며 세션 끝을 알린다', () async {
await api.signIn();
api.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.fail(401, 'AUTH_001'),
);
protectedRoute(validToken: 'never');
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'AUTH_002');
expect(api.store.accessToken, isNull);
expect(api.store.hasSession, isFalse);
expect(api.secure.values.containsKey(kRefreshTokenKey), isFalse);
expect(api.emitted.whereType<SessionEnded>(), hasLength(1));
expect(api.notifier.shownCodes, <String>['AUTH_002']);
// 원래 요청은 다시 보내지 않았다
expect(api.server.received('GET', _mailState), hasLength(1));
});
test('동시에 세션이 끝난 요청들의 AUTH_002 알림은 한 번만 뜬다', () async {
await api.signIn();
api.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.fail(401, 'AUTH_001'),
);
protectedRoute(validToken: 'never');
final List<Object> errors = await Future.wait(<Future<Object>>[
errorOf(api.dio.get<Object?>(_mailState)),
errorOf(api.dio.get<Object?>(_mailState)),
]);
expect(errors.map((Object e) => apiErrorOf(e).code), <String>[
'AUTH_002',
'AUTH_002',
]);
expect(refreshCount(), 1);
expect(api.notifier.shownCodes, <String>['AUTH_002']);
});
test('갱신이 일시 장애(503 · 연결 끊김)면 세션을 두고 그 코드로 알린다', () async {
await api.signIn();
api.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.fail(503, 'MAIL_001'),
);
protectedRoute(validToken: 'never');
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'MAIL_001');
expect(api.store.hasSession, isTrue);
expect(api.secure.values[kRefreshTokenKey], 'refresh-1');
expect(api.emitted.whereType<SessionEnded>(), isEmpty);
expect(api.notifier.shownCodes, <String>['MAIL_001']);
// 5초 안에는 같은 실패를 다시 쓴다 — 지난 뒤에 다시 묻는다
api.clock.advance(const Duration(seconds: 5));
api.server.on('POST', ApiPaths.appRefresh, (_) => FakeResponse.offline);
final ApiException offline = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(offline.code, 'SYS_001');
expect(api.store.hasSession, isTrue);
expect(refreshCount(), 2);
});
test('새 토큰으로 다시 보낸 요청도 401 이면 세션을 끝낸다(한 번만 다시 보낸다)', () async {
await api.signIn();
refreshIssuesNext();
protectedRoute(validToken: 'never');
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'AUTH_002');
expect(api.server.received('GET', _mailState), hasLength(2));
expect(refreshCount(), 1);
expect(api.store.hasSession, isFalse);
expect(api.emitted.whereType<SessionEnded>(), hasLength(1));
});
test('갱신 유예 응답(tokens: null)은 이어 쓸 토큰이 없어 세션 끝으로 다룬다', () async {
await api.signIn();
api.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.ok(<String, Object?>{
...sessionJson(),
'tokens': null,
}),
);
protectedRoute(validToken: 'never');
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'AUTH_002');
expect(api.store.hasSession, isFalse);
});
test('갱신하는 사이 로그아웃하면 받은 토큰을 쓰지 않고 그 세션을 Bearer 와 함께 끝내며 알리지 않는다', () async {
await api.signIn();
final Completer<void> release = Completer<void>();
api.server.on('POST', ApiPaths.appRefresh, (_) async {
await release.future;
return FakeResponse.ok(<String, Object?>{'tokens': tokensJson(2)});
});
api.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
protectedRoute(validToken: 'access-2');
final Future<Object> pending = errorOf(api.dio.get<Object?>(_mailState));
// 갱신이 서버에 닿을 때까지 기다린 뒤 로그아웃한다
await pumpEventsUntil(() => refreshCount() == 1, reason: '갱신이 서버에 닿기');
await api.store.clear(byUser: true);
release.complete();
final ApiException error = apiErrorOf(await pending);
expect(error.code, 'AUTH_001');
expect(api.store.accessToken, isNull);
expect(api.secure.values.containsKey(kRefreshTokenKey), isFalse);
await pumpEventsUntil(
() => api.server.received('POST', ApiPaths.appLogout).isNotEmpty,
reason: '조용한 로그아웃',
);
final RequestOptions logout = api.server
.received('POST', ApiPaths.appLogout)
.single;
expect(logout.data, <String, Object?>{'refreshToken': 'refresh-2'});
expect(bearerTokenOf(logout), 'access-2');
expect(api.notifier.shownCodes, isEmpty);
});
test(
'사용자가 로그아웃한 뒤 늦게 온, 로그인 중에 보낸 요청의 401 은 승격하지 않고(AUTH_001) 알리지 않는다',
() async {
await api.signIn();
final Completer<void> release = Completer<void>();
api.server.on('GET', _mailState, (_) async {
await release.future;
return FakeResponse.fail(401, 'AUTH_001');
});
final Future<Object> pending = errorOf(
api.dio.get<Object?>(_mailState),
);
await pumpEventsUntil(
() => api.server.received('GET', _mailState).isNotEmpty,
reason: '요청이 서버에 닿기',
);
await api.store.clear(byUser: true);
release.complete();
final ApiException error = apiErrorOf(await pending);
expect(error.code, 'AUTH_001');
expect(api.notifier.shownCodes, isEmpty);
expect(refreshCount(), 0);
expect(api.store.signedOutByUser, isTrue);
},
);
});
group('갱신하지 않는 경우', () {
test('로그인 · 갱신 · 로그아웃 요청의 401 은 갱신하지 않는다', () async {
await api.signIn();
api.server.on(
'POST',
ApiPaths.appLogin,
(_) => FakeResponse.fail(401, 'AUTH_004'),
);
final ApiException error = apiErrorOf(
await errorOf(api.dio.post<Object?>(ApiPaths.appLogin)),
);
expect(error.code, 'AUTH_004');
expect(refreshCount(), 0);
expect(api.store.hasSession, isTrue);
// 로그인 요청에는 Bearer 를 싣지 않는다
expect(bearerTokenOf(api.server.requests.single), isNull);
});
test('세션 없이 받은 401 은 AUTH_001 그대로다(세션 만료로 올리지 않는다)', () async {
protectedRoute(validToken: 'never');
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'AUTH_001');
expect(refreshCount(), 0);
expect(api.emitted, isEmpty);
});
});
group('AUTH_010 — 제한 다시 맞추기', () {
/// [attempts] 번째 요청까지 AUTH_010, 그 뒤는 성공하는 보호 API
void restrictedUntil(int attempts) {
int seen = 0;
api.server.on('GET', _mailState, (_) {
seen++;
return seen <= attempts
? FakeResponse.fail(403, 'AUTH_010')
: FakeResponse.ok(<String, Object?>{'state': 's1'});
});
}
test('제한을 확인했으면(restricted) 다시 보내지 않고 알리지 않는다', () async {
await api.signIn();
api.recheckAnswer = RestrictionCheck.restricted;
restrictedUntil(1);
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'AUTH_010');
expect(api.server.received('GET', _mailState), hasLength(1));
expect(api.notifier.shownCodes, isEmpty);
expect(api.emitted.whereType<SessionRecheckNeeded>(), hasLength(1));
expect(refreshCount(), 0);
});
test('옛 토큰이었으면(renewed) 원래 요청을 한 번 다시 보낸다', () async {
await api.signIn();
api.recheckAnswer = RestrictionCheck.renewed;
restrictedUntil(1);
final Response<Map<String, Object?>> response = await api.dio
.get<Map<String, Object?>>(_mailState);
expect(response.data, <String, Object?>{'state': 's1'});
final List<RequestOptions> sent = api.server.received('GET', _mailState);
expect(sent, hasLength(2));
expect(sent.last.extra[kRestrictionRetriedExtraKey], isTrue);
expect(api.notifier.shownCodes, isEmpty);
});
test('다시 보낸 요청도 AUTH_010 이면 다시 맞추지 않고 알린다', () async {
await api.signIn();
api.recheckAnswer = RestrictionCheck.renewed;
restrictedUntil(2);
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'AUTH_010');
expect(api.server.received('GET', _mailState), hasLength(2));
expect(api.emitted.whereType<SessionRecheckNeeded>(), hasLength(1));
expect(api.notifier.shownCodes, <String>['AUTH_010']);
});
test(
'확인하지 못했으면(unknown) 다시 보내지 않고 알린다 — 그사이 세션이 바뀌었으면(stale) 알리지 않는다',
() async {
await api.signIn();
restrictedUntil(99);
api.recheckAnswer = RestrictionCheck.unknown;
await errorOf(api.dio.get<Object?>(_mailState));
expect(api.notifier.shownCodes, <String>['AUTH_010']);
api.clock.advance(const Duration(seconds: 5));
api.recheckAnswer = RestrictionCheck.stale;
await errorOf(api.dio.get<Object?>(_mailState));
expect(api.notifier.shownCodes, <String>['AUTH_010']);
expect(api.server.received('GET', _mailState), hasLength(2));
},
);
test('듣는 쪽(세션 노티파이어)이 없으면 확인하지 못한 것으로 보고 알린다', () async {
final ApiHarness alone = ApiHarness();
await alone.store.prepare();
await alone.store.save(tokensFor(1, now: alone.clock.now));
alone.server.on(
'GET',
_mailState,
(_) => FakeResponse.fail(403, 'AUTH_010'),
);
final ApiException error = apiErrorOf(
await errorOf(alone.dio.get<Object?>(_mailState)),
);
expect(error.code, 'AUTH_010');
expect(alone.notifier.shownCodes, <String>['AUTH_010']);
});
test('사용자가 로그아웃한 뒤 늦게 온 AUTH_010 은 다시 맞추지 않고 알리지 않는다', () async {
await api.signIn();
final Completer<void> release = Completer<void>();
api.server.on('GET', _mailState, (_) async {
await release.future;
return FakeResponse.fail(403, 'AUTH_010');
});
final Future<Object> pending = errorOf(api.dio.get<Object?>(_mailState));
await pumpEventsUntil(
() => api.server.received('GET', _mailState).isNotEmpty,
reason: '요청이 서버에 닿기',
);
await api.store.clear(byUser: true);
release.complete();
expect(apiErrorOf(await pending).code, 'AUTH_010');
expect(api.emitted.whereType<SessionRecheckNeeded>(), isEmpty);
expect(api.notifier.shownCodes, isEmpty);
});
});
group('응답 풀기 · 알림 끄기', () {
test('성공한 바이트 · 스트림 응답은 풀지 않고 그대로 준다(첨부 받기)', () async {
await api.signIn();
api.server.on(
'GET',
'/files/f1',
(_) => FakeResponse.ok(<String, Object?>{'not': 'unwrapped'}),
);
final Response<List<int>> bytes = await api.dio.get<List<int>>(
'/files/f1',
options: Options(responseType: ResponseType.bytes),
);
expect(jsonDecode(utf8.decode(bytes.data!)), <String, Object?>{
'success': true,
'data': <String, Object?>{'not': 'unwrapped'},
});
final Response<ResponseBody> stream = await api.dio.get<ResponseBody>(
'/files/f1',
options: Options(responseType: ResponseType.stream),
);
final List<int> streamed = <int>[
for (final List<int> chunk in await stream.data!.stream.toList())
...chunk,
];
expect(utf8.decode(streamed), contains('"success":true'));
});
test('silent: true 면 알리지 않지만 코드는 그대로 온다', () async {
await api.signIn();
api.server.on(
'GET',
_mailState,
(_) => FakeResponse.fail(404, 'RES_001'),
);
final ApiException error = apiErrorOf(
await errorOf(
api.dio.get<Object?>(
_mailState,
options: Options(extra: <String, Object?>{kSilentExtraKey: true}),
),
),
);
expect(error.code, 'RES_001');
expect(api.notifier.shownCodes, isEmpty);
});
test('silent 에 코드 목록을 주면 그 코드만 알리지 않는다', () async {
await api.signIn();
api.server.on(
'GET',
_mailState,
(_) => FakeResponse.fail(404, 'RES_001'),
);
api.server.on('GET', '/other', (_) => FakeResponse.fail(429, 'REQ_001'));
await errorOf(
api.dio.get<Object?>(
_mailState,
options: ApiOptions.silentFor(<String>{'RES_001'}),
),
);
await errorOf(
api.dio.get<Object?>(
'/other',
options: ApiOptions.silentFor(<String>{'RES_001'}),
),
);
expect(api.notifier.shownCodes, <String>['REQ_001']);
});
test('같은 코드는 3초 안에 다시 알리지 않고, 지나면 다시 알린다', () async {
api.server.on(
'GET',
_mailState,
(_) => FakeResponse.fail(429, 'REQ_001'),
);
await errorOf(api.dio.get<Object?>(_mailState));
api.clock.advance(const Duration(seconds: 2));
await errorOf(api.dio.get<Object?>(_mailState));
api.clock.advance(const Duration(seconds: 2));
await errorOf(api.dio.get<Object?>(_mailState));
expect(api.notifier.shownCodes, <String>['REQ_001', 'REQ_001']);
});
test('200 에 success: false 가 와도 실패로 바꿔 코드로 알린다', () async {
api.server.on(
'GET',
_mailState,
(_) => const FakeResponse(200, <String, Object?>{
'success': false,
'error': <String, Object?>{'code': 'MAIL_001', 'traceId': 't-1'},
}),
);
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'MAIL_001');
expect(error.traceId, 't-1');
expect(api.notifier.shownCodes, <String>['MAIL_001']);
});
});
group('다시 보낸 요청 · 세션을 넘는 응답', () {
test('401 뒤 다시 보낸 요청이 AUTH_010 이면 제한을 다시 맞추고 확인했으면 알리지 않는다', () async {
await api.signIn();
api.recheckAnswer = RestrictionCheck.restricted;
api.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.ok(<String, Object?>{
...sessionJson(restriction: 'passwordChange'),
'tokens': tokensJson(2),
}),
);
api.server.on('GET', _mailState, (RequestOptions options) {
return bearerTokenOf(options) == 'access-2'
? FakeResponse.fail(403, 'AUTH_010')
: FakeResponse.fail(401, 'AUTH_001');
});
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'AUTH_010');
expect(api.recheckCount, 1);
expect(api.server.received('GET', _mailState), hasLength(2));
expect(api.notifier.shownCodes, isEmpty);
});
test('다시 보낸 요청이 401 인데 그사이 사용자가 로그아웃했으면 세션 만료로 알리지 않는다', () async {
await api.signIn();
refreshIssuesNext();
final Completer<void> release = Completer<void>();
api.server.on('GET', _mailState, (RequestOptions options) async {
if (bearerTokenOf(options) == 'access-2') await release.future;
return FakeResponse.fail(401, 'AUTH_001');
});
final Future<Object> pending = errorOf(api.dio.get<Object?>(_mailState));
await pumpEventsUntil(
() => api.server.received('GET', _mailState).length == 2,
reason: '다시 보낸 요청이 서버에 닿기',
);
await api.store.signOut();
release.complete();
final ApiException error = apiErrorOf(await pending);
expect(error.code, 'AUTH_001');
expect(api.store.signedOutByUser, isTrue);
expect(api.emitted.whereType<SessionEnded>(), isEmpty);
expect(api.notifier.shownCodes, isEmpty);
});
test('로그아웃 뒤 다른 직원이 로그인했으면 앞 세션의 늦은 401 을 새 토큰으로 다시 보내지 않는다', () async {
await api.signIn();
final Completer<void> release = Completer<void>();
api.server.on('GET', _mailState, (_) async {
await release.future;
return FakeResponse.fail(401, 'AUTH_001');
});
refreshIssuesNext();
final Future<Object> pending = errorOf(api.dio.get<Object?>(_mailState));
await pumpEventsUntil(
() => api.server.received('GET', _mailState).isNotEmpty,
reason: '앞 직원의 요청이 서버에 닿기',
);
await api.store.signOut();
await api.store.save(tokensFor(5, now: api.clock.now));
release.complete();
expect(apiErrorOf(await pending).code, 'AUTH_001');
expect(api.server.received('GET', _mailState), hasLength(1));
expect(refreshCount(), 0);
expect(api.store.accessToken, 'access-5');
expect(api.notifier.shownCodes, isEmpty);
});
test('같은 토큰으로 줄 선 AUTH_010 셋은 제한을 한 번만 묻는다(제한을 확인한 결과를 다시 쓴다)', () async {
await api.signIn();
api.recheckAnswer = RestrictionCheck.restricted;
api.server.on(
'GET',
_mailState,
(_) => FakeResponse.fail(403, 'AUTH_010'),
);
final List<Object> errors = await Future.wait(<Future<Object>>[
errorOf(api.dio.get<Object?>(_mailState)),
errorOf(api.dio.get<Object?>(_mailState)),
errorOf(api.dio.get<Object?>(_mailState)),
]);
expect(errors.map((Object e) => apiErrorOf(e).code).toSet(), <String>{
'AUTH_010',
});
expect(api.recheckCount, 1);
expect(api.notifier.shownCodes, isEmpty);
});
test('앞 요청이 다시 맞춰 새 토큰을 받았으면 줄 선 AUTH_010 은 묻지 않고 다시 보낸다', () async {
await api.signIn();
api.onRecheck = () async {
await api.store.save(tokensFor(2, now: api.clock.now));
return RestrictionCheck.renewed;
};
api.server.on('GET', _mailState, (RequestOptions options) {
return bearerTokenOf(options) == 'access-2'
? FakeResponse.ok(<String, Object?>{'state': 's1'})
: FakeResponse.fail(403, 'AUTH_010');
});
final List<Response<Object?>> responses =
await Future.wait(<Future<Response<Object?>>>[
api.dio.get<Object?>(_mailState),
api.dio.get<Object?>(_mailState),
api.dio.get<Object?>(_mailState),
]);
expect(
responses.map((Response<Object?> r) => r.statusCode).toSet(),
<int?>{200},
);
expect(api.recheckCount, 1);
expect(api.server.received('GET', _mailState), hasLength(6));
});
test('갱신한 토큰을 보안 저장소에 두지 못해도 이번 실행은 메모리의 새 토큰으로 이어 가고 다음에 다시 쓴다', () async {
await api.signIn();
refreshIssuesNext();
protectedRoute(validToken: 'access-2');
api.secure.failWrites = true;
final Response<Object?> response = await api.dio.get<Object?>(_mailState);
expect(response.statusCode, 200);
expect(api.store.accessToken, 'access-2');
expect(api.store.hasSession, isTrue);
expect(api.secure.values[kRefreshTokenKey], 'refresh-1');
expect(api.notifier.shownCodes, isEmpty);
// 저장소가 돌아오면 다음 읽기 때 메모리의 새 토큰을 다시 쓴다
api.secure.failWrites = false;
expect(await api.store.readRefreshToken(), 'refresh-2');
expect(api.secure.values[kRefreshTokenKey], 'refresh-2');
});
test('리프레시 토큰을 읽는 사이 로그아웃하면 세션 끝으로 다루지 않는다(알리지 않는다)', () async {
await api.coldStartWithRefreshToken('refresh-1');
refreshIssuesNext();
protectedRoute(validToken: 'access-2');
final Completer<void> gate = Completer<void>();
api.secure.readGate = gate;
final int readsBefore = api.secure.readCalls;
final Future<Object> pending = errorOf(api.dio.get<Object?>(_mailState));
// 갱신이 리프레시 토큰을 읽으려고 기다리는 동안 로그아웃한다
await pumpEventsUntil(
() => api.secure.readCalls > readsBefore,
reason: '갱신이 리프레시 토큰을 읽기 시작',
);
final Future<SignOutTokens> signingOut = api.store.signOut();
api.secure.readGate = null;
gate.complete();
await signingOut;
expect(apiErrorOf(await pending).code, 'AUTH_001');
expect(refreshCount(), 0);
expect(api.store.signedOutByUser, isTrue);
expect(api.emitted.whereType<SessionEnded>(), isEmpty);
expect(api.notifier.shownCodes, isEmpty);
});
test('유예 응답(tokens: null)이면 메모리의 액세스 토큰으로 서버 세션도 조용히 끝낸다', () async {
await api.signIn();
api.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.ok(<String, Object?>{
...sessionJson(),
'tokens': null,
}),
);
api.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
protectedRoute(validToken: 'never');
expect(
apiErrorOf(await errorOf(api.dio.get<Object?>(_mailState))).code,
'AUTH_002',
);
await pumpEventsUntil(
() => api.server.received('POST', ApiPaths.appLogout).isNotEmpty,
reason: '조용한 로그아웃',
);
final RequestOptions logout = api.server
.received('POST', ApiPaths.appLogout)
.single;
expect(logout.data, <String, Object?>{});
expect(bearerTokenOf(logout), 'access-1');
});
test('갱신 알림 · 결과에는 세션 칸만 싣고 토큰은 싣지 않는다', () async {
await api.signIn();
refreshIssuesNext();
protectedRoute(validToken: 'access-2');
await api.dio.get<Object?>(_mailState);
final SessionRefreshed refreshed = api.emitted
.whereType<SessionRefreshed>()
.single;
expect(refreshed.body.keys.toSet(), kSessionFieldNames);
expect(refreshed.body.containsKey('tokens'), isFalse);
});
test('비밀이 든 요청의 실패 예외는 본문과 Bearer 를 비운 채 온다', () async {
await api.signIn();
api.server.on(
'POST',
ApiPaths.appLogin,
(_) => FakeResponse.fail(401, 'AUTH_004'),
);
api.server.on(
'GET',
_mailState,
(_) => FakeResponse.fail(404, 'RES_001'),
);
const Map<String, Object?> loginBody = <String, Object?>{
'email': 'ara.kim@arasystem.it.kr',
'password': 'secret-pw',
};
final Object login = await errorOf(
api.dio.post<Object?>(
ApiPaths.appLogin,
data: loginBody,
options: ApiOptions.secret(silent: true),
),
);
final Object plain = await errorOf(api.dio.get<Object?>(_mailState));
final DioException loginError = login as DioException;
expect(loginError.requestOptions.data, isNull);
expect(loginError.response?.requestOptions.data, isNull);
final DioException plainError = plain as DioException;
expect(
plainError.requestOptions.headers.containsKey(kAuthorizationHeader),
isFalse,
);
// 서버에는 그대로 갔다(보낸 그때의 모습)
expect(
api.server.received('POST', ApiPaths.appLogin).single.data,
loginBody,
);
});
test('인터셉터 안에서 뜻밖의 예외가 나도 요청은 SYS_001 로 끝난다(줄이 서지 않는다)', () async {
await api.coldStartWithRefreshToken('refresh-1');
api.secure.throwOnRead = StateError('뜻밖의 오류(시험)');
protectedRoute(validToken: 'access-2');
final ApiException beforeSend = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(beforeSend.code, 'SYS_001');
expect(api.server.received('GET', _mailState), isEmpty);
// 401 뒤의 갱신에서 나도 같다
api.secure.throwOnRead = null;
await api.store.save(tokensFor(1, now: api.clock.now));
api.secure.throwOnRead = StateError('뜻밖의 오류(시험)');
final ApiException afterError = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(afterError.code, 'SYS_001');
expect(api.server.received('GET', _mailState), hasLength(1));
});
});
}
@@ -0,0 +1,77 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_config.dart';
import 'package:arasystem_app/core/api/session_events.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('제한 다시 맞추기 요청', () {
test('듣는 쪽이 없으면 기다리지 않고 unknown 이다', () async {
final SessionEventBus bus = SessionEventBus();
addTearDown(bus.close);
expect(await bus.requestRecheck(), RestrictionCheck.unknown);
});
test('듣는 쪽이 답하면 그 답이다', () async {
final SessionEventBus bus = SessionEventBus();
addTearDown(bus.close);
bus.stream.listen((SessionEvent event) {
if (event is SessionRecheckNeeded) {
event
..reply(RestrictionCheck.restricted)
// 두 번째 답은 쓰지 않는다
..reply(RestrictionCheck.renewed);
}
});
expect(await bus.requestRecheck(), RestrictionCheck.restricted);
});
testWidgets('듣는 쪽이 답하지 않으면 한도가 지나 unknown 이다(손으로 돌리는 시계)', (
WidgetTester tester,
) async {
final SessionEventBus bus = SessionEventBus();
bus.stream.listen((_) {});
RestrictionCheck? answer;
unawaited(
bus.requestRecheck().then((RestrictionCheck check) => answer = check),
);
await tester.pump(
kRestrictionRecheckTimeout - const Duration(seconds: 1),
);
expect(answer, isNull);
await tester.pump(const Duration(seconds: 1));
expect(answer, RestrictionCheck.unknown);
unawaited(bus.close());
});
test('한도는 안쪽 Dio 두 요청(세션 확인 · 갱신)의 연결 · 받기 한도를 다 쓴 것보다 길다', () {
expect(
kRestrictionRecheckTimeout >
(kApiConnectTimeout + kApiReceiveTimeout) * 2,
isTrue,
);
});
});
test('갱신 응답에서 세션 칸만 남긴다(토큰을 빼고)', () {
expect(
sessionFieldsOf(<String, Object?>{
'email': 'ara.kim@arasystem.it.kr',
'name': '김아라',
'restriction': null,
'twoFactorDeadline': null,
'tokens': <String, Object?>{'refreshToken': 'r'},
'other': 1,
}),
<String, Object?>{
'email': 'ara.kim@arasystem.it.kr',
'name': '김아라',
'restriction': null,
'twoFactorDeadline': null,
},
);
});
}
@@ -0,0 +1,47 @@
import 'package:arasystem_app/core/api/user_agent.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('User-Agent', () {
test('계약 1절 "앱 로그인"의 꼴로 만든다', () {
expect(
buildUserAgent(
appVersion: '1.0.0',
osName: 'Android',
osVersion: '15',
model: 'SM-S921N',
),
'ARASYSTEM-App/1.0.0 (Android 15; SM-S921N)',
);
});
test('모르는 칸은 빼고 아는 것만 싣는다', () {
expect(
buildUserAgent(appVersion: null, osName: 'Android', osVersion: '15'),
'ARASYSTEM-App (Android 15)',
);
expect(
buildUserAgent(appVersion: '1.0.0', osName: ''),
'ARASYSTEM-App/1.0.0',
);
});
test('머리글을 깨는 글자(괄호 · 쌍반점 · 한글)는 지우고 길이를 줄인다', () {
final String agent = buildUserAgent(
appVersion: '1.0.0',
osName: 'Android',
osVersion: '15',
model: '갤럭시 (S24); Ultra',
);
expect(agent, 'ARASYSTEM-App/1.0.0 (Android 15; S24 Ultra)');
final String long = buildUserAgent(
appVersion: '1.0.0',
osName: 'Android',
model: 'X' * 200,
);
expect(long.length, lessThan(120));
expect(RegExp(r'^[\x20-\x7E]+$').hasMatch(long), isTrue);
});
});
}
@@ -0,0 +1,150 @@
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/modules/module_routes.dart';
import 'package:arasystem_app/core/push/push_target.dart';
import 'package:arasystem_app/core/router/module_registry.dart';
import 'package:arasystem_app/features/mail/mail_routes.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
void main() {
group('모듈 목록', () {
test('웹 app-modules.ts 와 같은 id · 이름 · 차례다(설정은 맨 끝)', () {
// 짝: frontend/src/shared/constants/app-modules.ts 의 APP_MODULES — 한쪽을 바꾸면 둘 다
expect(
kAppModules.map((AppModule m) => '${m.id.name}:${m.label}').toList(),
<String>[
'mail:메일',
'calendar:캘린더',
'files:파일',
'contacts:주소록',
'settings:설정',
],
);
});
test('모든 모듈 id 가 목록에 정확히 한 번 있다', () {
for (final AppModuleId id in AppModuleId.values) {
expect(
kAppModules.where((AppModule m) => m.id == id),
hasLength(1),
reason: id.name,
);
}
expect(kAppModules, hasLength(AppModuleId.values.length));
});
test('홈은 넘겨받은 목록을 본다 — 메일이 꺼졌으면 켜진 첫 모듈', () {
final List<AppModule> mailOff = <AppModule>[
for (final AppModule m in kAppModules)
m.id == AppModuleId.mail
? AppModule(
id: m.id,
label: m.label,
icon: m.icon,
selectedIcon: m.selectedIcon,
isEnabled: false,
)
: m,
];
expect(homeLocationOf(), '/mail');
expect(homeLocationOf(mailOff), '/settings');
});
test('첫 판은 메일 · 설정만 켠다 — 켜진 것만 목록 차례대로', () {
expect(
enabledAppModules().map((AppModule m) => m.id).toList(),
<AppModuleId>[AppModuleId.mail, AppModuleId.settings],
);
});
test('주소는 모듈 이름부터 시작하고, 경로의 첫 칸으로 켜진 모듈을 찾는다', () {
expect(findAppModule(AppModuleId.mail).rootPath, '/mail');
expect(homeLocation, '/mail');
expect(enabledModuleOfPath('/mail/messages/1')?.id, AppModuleId.mail);
expect(enabledModuleOfPath('/settings')?.id, AppModuleId.settings);
expect(enabledModuleOfPath('/calendar'), isNull);
expect(enabledModuleOfPath('/bogus'), isNull);
expect(enabledModuleOfPath('/'), isNull);
});
test('켜진 모듈은 모두 라우트 묶음이 있고, 첫 라우트가 모듈 경로다', () {
final List<ModuleRoutes> routes = enabledModuleRoutes();
expect(routes.map((ModuleRoutes r) => r.id).toList(), <AppModuleId>[
AppModuleId.mail,
AppModuleId.settings,
]);
for (final ModuleRoutes module in routes) {
final List<RouteBase> built = module.buildRoutes(
ModuleRouteContext(rootNavigatorKey: GlobalKey<NavigatorState>()),
);
expect(
(built.first as GoRoute).path,
findAppModule(module.id).rootPath,
reason: module.id.name,
);
}
});
test('켰는데 라우트 묶음이 없는 모듈은 라우터를 만들 때 멈춘다', () {
final List<AppModule> withCalendar = <AppModule>[
for (final AppModule m in kAppModules)
m.id == AppModuleId.calendar
? AppModule(
id: m.id,
label: m.label,
icon: m.icon,
selectedIcon: m.selectedIcon,
isEnabled: true,
)
: m,
];
expect(() => enabledModuleRoutes(withCalendar), throwsStateError);
});
});
group('알림 · 깊은 링크', () {
test('모듈 이름과 id 로 화면을 고른다', () {
expect(
locationForPush(
PushTarget.fromData(<String, Object?>{'module': 'mail', 'id': 'm 1'}),
moduleRoutesOf,
),
MailRoutes.message('m 1'),
);
expect(
locationForPush(
PushTarget.fromData(<String, Object?>{'module': 'mail'}),
moduleRoutesOf,
),
'/mail',
);
expect(
locationForPush(
PushTarget.fromData(<String, Object?>{
'module': 'settings',
'id': 'x',
}),
moduleRoutesOf,
),
'/settings',
);
});
test('모르는 · 꺼진 모듈이면 메일 첫 화면이다', () {
expect(
locationForPush(
PushTarget.fromData(<String, Object?>{
'module': 'calendar',
'id': 'e1',
}),
moduleRoutesOf,
),
homeLocation,
);
expect(PushTarget.fromData(<String, Object?>{'module': 'bogus'}), isNull);
expect(locationForPush(null, moduleRoutesOf), homeLocation);
});
});
}
@@ -0,0 +1,155 @@
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/router/app_routes.dart';
import 'package:arasystem_app/core/router/session_redirect.dart';
import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
AsyncValue<SessionState> _signedIn([SessionRestriction? restriction]) {
return AsyncData<SessionState>(
SignedIn(
AuthSessionInfo(
email: 'ara.kim@arasystem.it.kr',
name: '김아라',
restriction: restriction,
),
),
);
}
void main() {
group('세션 상태로 화면 고르기', () {
test('되살리는 중이면 시작 화면으로 가고, 이미 있으면 그대로 둔다', () {
const AsyncValue<SessionState> loading = AsyncLoading<SessionState>();
expect(resolveSessionRedirect(loading, homeLocation), AppRoutes.start);
expect(resolveSessionRedirect(loading, AppRoutes.start), isNull);
});
test('되살리기가 장애로 실패해도 시작 화면이다([다시 시도] 자리)', () {
final AsyncValue<SessionState> failed = AsyncError<SessionState>(
Exception('offline'),
StackTrace.empty,
);
expect(resolveSessionRedirect(failed, AppRoutes.login), AppRoutes.start);
});
test('로그아웃이면 어디서든 로그인 화면으로 간다', () {
const AsyncValue<SessionState> signedOut = AsyncData<SessionState>(
SignedOut(),
);
expect(resolveSessionRedirect(signedOut, homeLocation), AppRoutes.login);
expect(
resolveSessionRedirect(signedOut, AppRoutes.loginCode),
AppRoutes.login,
);
expect(resolveSessionRedirect(signedOut, AppRoutes.login), isNull);
});
test('코드 단계면 코드 화면이다', () {
const AsyncValue<SessionState> code = AsyncData<SessionState>(
CodeRequired('ara.kim@arasystem.it.kr'),
);
expect(
resolveSessionRedirect(code, AppRoutes.login),
AppRoutes.loginCode,
);
expect(resolveSessionRedirect(code, AppRoutes.loginCode), isNull);
});
test('제한된 세션은 그 단계 화면에서 벗어나지 못한다', () {
expect(
resolveSessionRedirect(
_signedIn(SessionRestriction.passwordChange),
homeLocation,
),
AppRoutes.passwordChange,
);
expect(
resolveSessionRedirect(
_signedIn(SessionRestriction.twoFactorSetup),
AppRoutes.passwordChange,
),
AppRoutes.twoFactorSetup,
);
expect(
resolveSessionRedirect(
_signedIn(SessionRestriction.unknown),
homeLocation,
),
AppRoutes.restricted,
);
expect(
resolveSessionRedirect(
_signedIn(SessionRestriction.twoFactorSetup),
AppRoutes.twoFactorSetup,
),
isNull,
);
});
test('로그인했으면 로그인 전 화면에서 메일로 가고, 로그인 뒤 화면은 그대로 둔다', () {
expect(
resolveSessionRedirect(_signedIn(), AppRoutes.start),
homeLocation,
);
expect(
resolveSessionRedirect(_signedIn(), AppRoutes.login),
homeLocation,
);
expect(
resolveSessionRedirect(_signedIn(), AppRoutes.passwordChange),
homeLocation,
);
expect(resolveSessionRedirect(_signedIn(), homeLocation), isNull);
expect(resolveSessionRedirect(_signedIn(), '/mail/inbox/m1'), isNull);
});
test('로그인했는데 모르는 모듈 · 꺼진 모듈의 주소면 메일 첫 화면으로 간다', () {
expect(resolveSessionRedirect(_signedIn(), '/calendar'), homeLocation);
expect(resolveSessionRedirect(_signedIn(), '/files/f1'), homeLocation);
expect(resolveSessionRedirect(_signedIn(), '/nowhere'), homeLocation);
expect(resolveSessionRedirect(_signedIn(), '/'), homeLocation);
expect(resolveSessionRedirect(_signedIn(), '/settings/about'), isNull);
});
test('홈은 넘겨받은 모듈 목록을 따른다 — 메일이 꺼졌으면 켜진 첫 모듈로', () {
final List<AppModule> mailOff = <AppModule>[
for (final AppModule m in kAppModules)
m.id == AppModuleId.mail
? AppModule(
id: m.id,
label: m.label,
icon: m.icon,
selectedIcon: m.selectedIcon,
isEnabled: false,
)
: m,
];
expect(
resolveSessionRedirect(_signedIn(), AppRoutes.login, modules: mailOff),
'/settings',
);
expect(
resolveSessionRedirect(_signedIn(), '/mail', modules: mailOff),
'/settings',
);
});
test('모르는 제한 이름은 제한이 있는 것(unknown)으로 읽는다', () {
expect(SessionRestriction.parse(null), isNull);
expect(SessionRestriction.parse(''), isNull);
expect(
SessionRestriction.parse('passwordChange'),
SessionRestriction.passwordChange,
);
expect(SessionRestriction.parse('newThing'), SessionRestriction.unknown);
expect(SessionRestriction.parse('unknown'), SessionRestriction.unknown);
});
});
}
@@ -0,0 +1,140 @@
import 'package:arasystem_app/core/storage/auth_tokens.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/fakes.dart';
void main() {
group('첫 실행', () {
test('첫 실행 표시가 없으면 보안 저장소에 남은 토큰을 지우고 표시를 남긴다', () async {
final ApiHarness api = ApiHarness(firstRunMarked: false);
// 앱을 지웠다 다시 깔았다 — iOS Keychain 에는 옛 토큰이 남아 있다
api.secure.values[kRefreshTokenKey] = 'refresh-old';
await api.store.prepare();
expect(api.secure.values, isEmpty);
expect(api.secure.deleteAllCount, 1);
expect(api.firstRun.marked, isTrue);
expect(api.store.hasSession, isFalse);
});
test('첫 실행 표시가 있으면 남은 리프레시 토큰으로 세션이 있다고 본다', () async {
final ApiHarness api = ApiHarness();
api.secure.values[kRefreshTokenKey] = 'refresh-1';
await api.store.prepare();
expect(api.secure.deleteAllCount, 0);
expect(api.store.hasSession, isTrue);
// 액세스 토큰은 메모리에만 있으므로 새로 띄우면 없다
expect(api.store.accessToken, isNull);
expect(api.store.hasUsableAccessToken, isFalse);
});
test('두 번째 실행부터는 지우지 않는다', () async {
final ApiHarness api = ApiHarness(firstRunMarked: false);
await api.store.prepare();
api.secure.values[kRefreshTokenKey] = 'refresh-1';
await api.store.prepare();
expect(api.secure.values[kRefreshTokenKey], 'refresh-1');
expect(api.secure.deleteAllCount, 1);
});
});
group('보관', () {
late ApiHarness api;
setUp(() async {
api = ApiHarness();
await api.store.prepare();
});
test('리프레시 토큰은 보안 저장소에, 액세스 토큰은 메모리에만 둔다', () async {
await api.store.save(tokensFor(1));
expect(api.secure.values, <String, String>{
kRefreshTokenKey: 'refresh-1',
});
expect(api.store.accessToken, 'access-1');
expect(api.store.hasSession, isTrue);
expect(await api.store.readRefreshToken(), 'refresh-1');
});
test('저장 · 지우기마다 토큰 세대가 오른다', () async {
final int start = api.store.generation;
await api.store.save(tokensFor(1));
await api.store.clear();
expect(api.store.generation, start + 2);
expect(api.store.accessToken, isNull);
expect(api.store.hasSession, isFalse);
expect(api.secure.values, isEmpty);
});
test('액세스 토큰은 만료 30초 전부터 쓸 수 없는 것으로 본다', () async {
await api.store.save(tokensFor(1, now: api.clock.now));
api.clock.advance(const Duration(minutes: 59, seconds: 29));
expect(api.store.hasUsableAccessToken, isTrue);
api.clock.advance(const Duration(seconds: 1));
expect(api.store.hasUsableAccessToken, isFalse);
});
test('남은 시간은 응답의 Date(서버 시각)로 잰다 — 기기 시계가 2시간 빨라도 쓸 수 있다', () async {
final DateTime serverNow = api.clock.now.subtract(
const Duration(hours: 2),
);
final AuthTokens tokens = AuthTokens.tryParse(
tokensJson(1, now: serverNow),
serverTime: serverNow,
)!;
await api.store.save(tokens);
// 기기 시계로는 이미 지난 만료 시각이지만 받은 때부터 1시간을 쓸 수 있다
expect(tokens.accessExpiresAt.isBefore(api.clock.now), isTrue);
expect(api.store.hasUsableAccessToken, isTrue);
api.clock.advance(const Duration(minutes: 59, seconds: 29));
expect(api.store.hasUsableAccessToken, isTrue);
api.clock.advance(const Duration(seconds: 1));
expect(api.store.hasUsableAccessToken, isFalse);
});
test('기기 시계를 뒤로 돌리면 지난 시간을 모르니 미리 갱신하는 쪽으로 본다', () async {
await api.store.save(tokensFor(1, now: api.clock.now));
api.clock.advance(const Duration(minutes: -1));
expect(api.store.hasUsableAccessToken, isFalse);
});
test('사용자가 로그아웃한 것을 기억하고, 새 로그인이 지운다', () async {
await api.store.save(tokensFor(1));
await api.store.clear(byUser: true);
expect(api.store.signedOutByUser, isTrue);
await api.store.save(tokensFor(2));
expect(api.store.signedOutByUser, isFalse);
await api.store.clear();
expect(api.store.signedOutByUser, isFalse);
});
test('토큰 값은 toString 에 보이지 않는다', () {
expect(tokensFor(1).toString(), isNot(contains('access-1')));
expect(tokensFor(1).toString(), isNot(contains('refresh-1')));
});
test('모양이 틀린 tokens 칸은 읽지 않는다', () {
expect(AuthTokens.tryParse(null), isNull);
expect(
AuthTokens.tryParse(<String, Object?>{'accessToken': 'a'}),
isNull,
);
expect(AuthTokens.tryParse(tokensJson(3))?.refreshToken, 'refresh-3');
});
});
}
@@ -0,0 +1,57 @@
import 'package:arasystem_app/core/theme/app_colors.dart';
import 'package:arasystem_app/core/theme/app_fonts.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('테마', () {
final ThemeData theme = AppTheme.light();
test('웹 토큰의 브랜드 색을 시드 없이 그대로 쓴다', () {
expect(theme.colorScheme.primary, AppColors.brand);
expect(theme.colorScheme.primaryContainer, AppColors.brandSubtle);
expect(theme.colorScheme.secondary, AppColors.brandSurface);
expect(theme.colorScheme.error, AppColors.danger);
expect(theme.colorScheme.onSurface, AppColors.text);
expect(theme.extension<AppExtraColors>()?.textMuted, AppColors.textMuted);
});
test('글꼴은 Pretendard, 본문은 15 기준이다', () {
expect(theme.textTheme.bodyMedium?.fontFamily, AppFonts.family);
expect(theme.textTheme.bodyMedium?.fontSize, AppTypography.bodyMedium);
expect(AppTypography.bodyMedium, 15);
});
test('단추의 누르는 곳은 48dp 이상이다', () {
expect(AppSize.touchMin, 48);
expect(
theme.filledButtonTheme.style?.minimumSize?.resolve(<WidgetState>{}),
const Size(AppSize.touchMin, AppSize.touchMin),
);
expect(theme.materialTapTargetSize, MaterialTapTargetSize.padded);
});
});
test('Pretendard 의 OFL 1.1 라이선스를 라이선스 화면에 올린다', () async {
AppFonts.registerFontLicenses();
final List<LicenseEntry> entries = await LicenseRegistry.licenses.toList();
final Iterable<LicenseEntry> pretendard = entries.where(
(LicenseEntry entry) =>
entry.packages.contains(AppFonts.licensePackageName),
);
expect(pretendard, isNotEmpty);
final String text = pretendard.first.paragraphs
.map((LicenseParagraph p) => p.text)
.join('\n');
expect(text, contains('SIL OPEN FONT LICENSE Version 1.1'));
expect(text, contains('Reserved Font Name Pretendard'));
});
}
+7 -1
View File
@@ -19,7 +19,8 @@ void main() {
});
test('클라이언트 로직이 참조하는 코드가 사전에 존재한다', () {
// dio_client 의 상태코드 역매핑과 세션 만료 승격이 이 코드들을 직접 사용한다.
// 상태 코드 역매핑(api_error_mapper) · 세션 만료 승격 · AUTH_010 다시 확인(auth_interceptor) ·
// 로그인 화면이 칸에 보이는 실패(auth_repository 의 kLoginInlineCodes)가 이 코드들을 직접 사용한다.
//
// ⚠️ 전체 목록을 비교하지 않는 이유: 세 플랫폼의 사전은 backend 레지스트리에서
// 생성되므로 목록 일치는 구조적으로 보장되고, CI 가 드리프트를 검사한다.
@@ -30,9 +31,14 @@ void main() {
'AUTH_001',
'AUTH_002',
'AUTH_003',
'AUTH_004',
'AUTH_006',
'AUTH_007',
'AUTH_010',
'VAL_001',
'RES_001',
'BIZ_001',
'FILE_002',
];
for (final code in usedByClient) {
@@ -0,0 +1,663 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/api/session_events.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/fakes.dart';
import '../../support/provider_harness.dart';
void main() {
late ProviderHarness h;
late ProviderContainer container;
Future<ProviderContainer> start({String? refreshToken}) async {
await h.prepare(refreshToken: refreshToken);
container = h.container();
addTearDown(container.dispose);
// 세션을 듣기 시작해 되살리기를 돌린다
container.listen(sessionProvider, (_, _) {});
return container;
}
Future<SessionState?> settled() async {
try {
return await container.read(sessionProvider.future);
} on Object {
return null;
}
}
SessionNotifier notifier() => container.read(sessionProvider.notifier);
SessionState? current() => container.read(sessionProvider).value;
SessionRestriction? restrictionNow() {
final SessionState? state = current();
return state is SignedIn ? state.info.restriction : null;
}
void refreshGives(int n, {String? restriction}) {
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.ok(<String, Object?>{
...sessionJson(restriction: restriction),
'tokens': tokensJson(n, now: h.clock.now),
}),
);
}
/// 코드 단계로 답하는 로그인
FakeResponse codeStep() => FakeResponse.ok(<String, Object?>{
'status': 'twoFactorRequired',
'email': null,
'name': null,
'restriction': null,
'twoFactorDeadline': null,
'tokens': null,
});
/// 로그인을 마친 응답 ([n] 번째 토큰)
FakeResponse signedInWith(int n, {String? restriction}) =>
FakeResponse.ok(<String, Object?>{
'status': 'signedIn',
...sessionJson(restriction: restriction),
'tokens': tokensJson(n, now: h.clock.now),
});
List<RequestOptions> logouts() =>
h.server.received('POST', ApiPaths.appLogout);
setUp(() => h = ProviderHarness());
group('되살리기', () {
test('리프레시 토큰이 없으면 서버에 묻지 않고 로그아웃 상태다', () async {
await start();
expect(await settled(), const SignedOut());
expect(h.server.requests, isEmpty);
});
test('리프레시 토큰이 있으면 갱신 뒤 me 로 세션을 되살린다', () async {
await start(refreshToken: 'refresh-1');
refreshGives(2);
h.server.on(
'GET',
ApiPaths.me,
(_) => FakeResponse.ok(sessionJson(restriction: 'passwordChange')),
);
final SessionState? state = await settled();
expect(state, isA<SignedIn>());
expect(restrictionNow(), SessionRestriction.passwordChange);
expect(
bearerTokenOf(h.server.received('GET', ApiPaths.me).single),
'access-2',
);
// 되살리기는 알리지 않는다
expect(h.notifier.shownCodes, isEmpty);
});
test('갱신이 거절되면 토큰을 지우고 알림 없이 로그아웃 상태로 시작한다', () async {
await start(refreshToken: 'refresh-1');
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.fail(401, 'AUTH_001'),
);
expect(await settled(), const SignedOut());
expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse);
expect(h.notifier.shownCodes, isEmpty);
});
test('연결이 끊겨 되살리지 못하면 세션을 두고 오류 상태다 — [다시 시도]는 곧바로 다시 묻는다', () async {
await start(refreshToken: 'refresh-1');
h.server.on('POST', ApiPaths.appRefresh, (_) => FakeResponse.offline);
expect(await settled(), isNull);
expect(container.read(sessionProvider).hasError, isTrue);
expect(h.secure.values[kRefreshTokenKey], 'refresh-1');
// 5초 안에 눌러도 방금의 실패를 다시 쓰지 않는다
refreshGives(2);
h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()));
notifier().retry();
expect(await settled(), isA<SignedIn>());
expect(h.server.received('POST', ApiPaths.appRefresh), hasLength(2));
});
});
group('로그인', () {
test('2단계 인증을 켠 직원은 코드 단계로 가고, 코드와 비밀번호를 함께 보내 로그인한다', () async {
await start();
await settled();
h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) {
final Map<String, Object?> body = options.data as Map<String, Object?>;
return body['otpCode'] == null ? codeStep() : signedInWith(1);
});
await notifier().signIn(
email: 'ara.kim@arasystem.it.kr',
password: 'pw-1234',
);
expect(current(), const CodeRequired('ara.kim@arasystem.it.kr'));
await notifier().submitCode('123456');
expect(current(), isA<SignedIn>());
expect(
h.server.received('POST', ApiPaths.appLogin).last.data,
<String, Object?>{
'email': 'ara.kim@arasystem.it.kr',
'password': 'pw-1234',
'otpCode': '123456',
},
);
expect(h.secure.values[kRefreshTokenKey], 'refresh-1');
expect(h.store.accessToken, 'access-1');
// 제한 없이 로그인을 마치면 쥐고 있던 비밀번호를 지운다
expect(notifier().heldLogin, isNull);
});
test('코드 단계에 10분 넘게 머물면 보내지 않고 첫 단계로 돌아간다', () async {
await start();
await settled();
h.server.on('POST', ApiPaths.appLogin, (_) => codeStep());
await notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw');
expect(notifier().heldLogin?.password, 'pw');
h.clock.advance(const Duration(minutes: 10));
await notifier().submitCode('123456');
expect(current(), const SignedOut(reason: SignOutReason.codeTimedOut));
expect(h.server.received('POST', ApiPaths.appLogin), hasLength(1));
});
test('제한된 세션으로 로그인하면 새 비밀번호 단계가 쓸 비밀번호를 쥐고 있는다', () async {
await start();
await settled();
h.server.on(
'POST',
ApiPaths.appLogin,
(_) => signedInWith(1, restriction: 'passwordChange'),
);
await notifier().signIn(
email: 'ara.kim@arasystem.it.kr',
password: 'temp-pw',
);
expect(restrictionNow(), SessionRestriction.passwordChange);
expect(notifier().heldLogin?.password, 'temp-pw');
});
test('로그인 화면이 칸에 보이는 실패(AUTH_004)는 알리지 않고 코드로 던진다', () async {
await start();
await settled();
h.server.on(
'POST',
ApiPaths.appLogin,
(_) => FakeResponse.fail(401, 'AUTH_004'),
);
final Object error = await errorOf(
notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'wrong'),
);
expect(apiErrorOf(error).code, 'AUTH_004');
expect(h.notifier.shownCodes, isEmpty);
expect(current(), const SignedOut());
});
test('응답 모양이 계약과 다르면 DioException(SYS_001)으로 던진다', () async {
await start();
await settled();
h.server.on(
'POST',
ApiPaths.appLogin,
(_) => FakeResponse.ok(<String, Object?>{'status': 'signedIn'}),
);
final Object error = await errorOf(
notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw'),
);
expect(error, isA<DioException>());
expect(apiErrorOf(error).code, 'SYS_001');
expect(h.store.hasSession, isFalse);
});
});
group('늦게 온 결과 버리기 (시도 세대)', () {
test('[다른 계정으로 로그인] 뒤에 온 코드 단계의 로그인 결과는 쓰지 않고 그 세션을 조용히 끝낸다', () async {
await start();
await settled();
final Completer<void> release = Completer<void>();
h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) async {
final Map<String, Object?> body = options.data as Map<String, Object?>;
if (body['otpCode'] == null) return codeStep();
await release.future;
return signedInWith(1);
});
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
await notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw');
final Future<void> code = notifier().submitCode('123456');
await pumpEventsUntil(
() => h.server.received('POST', ApiPaths.appLogin).length == 2,
reason: '코드 요청이 서버에 닿기',
);
await notifier().switchAccount();
release.complete();
await code;
expect(current(), const SignedOut(reason: SignOutReason.switchAccount));
expect(h.store.hasSession, isFalse);
expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse);
await pumpEventsUntil(() => logouts().isNotEmpty, reason: '조용한 로그아웃');
expect(logouts().single.data, <String, Object?>{
'refreshToken': 'refresh-1',
});
expect(bearerTokenOf(logouts().single), 'access-1');
});
test('로그아웃 뒤에 온 로그인 결과도 버린다', () async {
await start();
await settled();
final Completer<void> release = Completer<void>();
h.server.on('POST', ApiPaths.appLogin, (_) async {
await release.future;
return signedInWith(1);
});
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
final Future<void> signIn = notifier().signIn(
email: 'ara.kim@arasystem.it.kr',
password: 'pw',
);
await pumpEventsUntil(
() => h.server.received('POST', ApiPaths.appLogin).isNotEmpty,
reason: '로그인 요청이 서버에 닿기',
);
await notifier().signOut();
release.complete();
await signIn;
expect(current(), const SignedOut());
expect(h.store.accessToken, isNull);
await pumpEventsUntil(() => logouts().isNotEmpty, reason: '조용한 로그아웃');
expect(bearerTokenOf(logouts().single), 'access-1');
});
test('제한을 다시 맞추는 사이 로그아웃하면 결과를 쓰지 않는다(stale)', () async {
await start(refreshToken: 'refresh-1');
refreshGives(2);
final Completer<void> release = Completer<void>();
int meCalls = 0;
h.server.on('GET', ApiPaths.me, (_) async {
meCalls++;
if (meCalls == 1) return FakeResponse.ok(sessionJson());
await release.future;
return FakeResponse.ok(sessionJson(restriction: 'twoFactorSetup'));
});
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
expect(await settled(), isA<SignedIn>());
final Future<RestrictionCheck> check = notifier().recheck();
await pumpEventsUntil(() => meCalls == 2, reason: '세션 확인이 서버에 닿기');
await notifier().signOut();
release.complete();
expect(await check, RestrictionCheck.stale);
expect(current(), const SignedOut());
});
});
group('로그인한 뒤', () {
Future<void> signedIn({String? restriction}) async {
await start(refreshToken: 'refresh-1');
refreshGives(2, restriction: restriction);
h.server.on(
'GET',
ApiPaths.me,
(_) => FakeResponse.ok(sessionJson(restriction: restriction)),
);
expect(await settled(), isA<SignedIn>());
}
test('갱신이 거절되면(다른 곳에서 끊음) 로그인 화면으로 간다', () async {
await signedIn();
h.clock.advance(const Duration(hours: 2));
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.fail(401, 'AUTH_001'),
);
h.server.on('GET', '/mailboxes', (_) => FakeResponse.ok(<Object?>[]));
await errorOf(container.read(dioProvider).get<Object?>('/mailboxes'));
await pumpEventsUntil(() => current() is SignedOut, reason: '세션 끝 알림');
expect(current(), const SignedOut(reason: SignOutReason.expired));
expect(h.notifier.shownCodes, <String>['AUTH_002']);
});
test(
'AUTH_010 — me 에 제한이 있으면 그 단계로 옮기고 원래 요청은 다시 보내지 않으며 알리지 않는다',
() async {
await signedIn();
h.server.on(
'GET',
'/mailboxes',
(_) => FakeResponse.fail(403, 'AUTH_010'),
);
h.server.on(
'GET',
ApiPaths.me,
(_) => FakeResponse.ok(sessionJson(restriction: 'twoFactorSetup')),
);
final Object error = await errorOf(
container.read(dioProvider).get<Object?>('/mailboxes'),
);
expect(apiErrorOf(error).code, 'AUTH_010');
expect(restrictionNow(), SessionRestriction.twoFactorSetup);
expect(h.server.received('GET', '/mailboxes'), hasLength(1));
// 제한이 있으면 갱신하지 않는다 — 되살리기 때의 한 번뿐
expect(h.server.received('POST', ApiPaths.appRefresh), hasLength(1));
expect(h.notifier.shownCodes, isEmpty);
},
);
test('AUTH_010 — me 에 제한이 없으면(옛 토큰) 갱신한 뒤 원래 요청을 한 번 다시 보낸다', () async {
await signedIn();
int mailboxCalls = 0;
h.server.on('GET', '/mailboxes', (_) {
mailboxCalls++;
return mailboxCalls == 1
? FakeResponse.fail(403, 'AUTH_010')
: FakeResponse.ok(<Object?>['inbox']);
});
refreshGives(3);
final Response<Object?> response = await container
.read(dioProvider)
.get<Object?>('/mailboxes');
expect(response.data, <Object?>['inbox']);
final List<RequestOptions> sent = h.server.received('GET', '/mailboxes');
expect(sent.map(bearerTokenOf), <String?>['access-2', 'access-3']);
expect(h.notifier.shownCodes, isEmpty);
expect(current(), isA<SignedIn>());
});
test('갱신 응답의 제한이 바뀌면 화면 상태도 바꾼다', () async {
await signedIn();
h.clock.advance(const Duration(hours: 2));
refreshGives(3, restriction: 'twoFactorSetup');
h.server.on('GET', '/mailboxes', (_) => FakeResponse.ok(<Object?>[]));
await container.read(dioProvider).get<Object?>('/mailboxes');
await pumpEventsUntil(
() => restrictionNow() == SessionRestriction.twoFactorSetup,
reason: '갱신 응답의 제한 반영',
);
expect(restrictionNow(), SessionRestriction.twoFactorSetup);
});
test('제한이 풀리면 쥐고 있던 비밀번호를 바로 지운다', () async {
await start();
await settled();
h.server.on(
'POST',
ApiPaths.appLogin,
(_) => signedInWith(1, restriction: 'passwordChange'),
);
await notifier().signIn(
email: 'ara.kim@arasystem.it.kr',
password: 'temp-pw',
);
expect(notifier().heldLogin, isNotNull);
// 새 비밀번호를 정한 뒤 곧바로 갱신 — 응답에 제한이 없다
refreshGives(2);
await container.read(authRepositoryProvider).refresh();
await pumpEventsUntil(
() => restrictionNow() == null && current() is SignedIn,
reason: '제한이 풀린 갱신 반영',
);
expect(notifier().heldLogin, isNull);
});
test('로그아웃하면 토큰을 지우고 서버 세션을 리프레시 토큰과 Bearer 로 끝낸다', () async {
await signedIn();
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
await notifier().signOut();
expect(current(), const SignedOut());
expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse);
expect(h.store.accessToken, isNull);
expect(h.store.signedOutByUser, isTrue);
expect(logouts().single.data, <String, Object?>{
'refreshToken': 'refresh-2',
});
expect(bearerTokenOf(logouts().single), 'access-2');
});
test('보안 저장소를 읽지 못해도 이 기기의 토큰은 지우고 Bearer 로 서버 세션을 끝낸다', () async {
await signedIn();
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
h.secure.failReads = true;
await notifier().signOut();
expect(current(), const SignedOut());
expect(h.store.accessToken, isNull);
expect(h.store.hasSession, isFalse);
expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse);
expect(logouts().single.data, <String, Object?>{});
expect(bearerTokenOf(logouts().single), 'access-2');
});
test('로그아웃 요청이 닿지 않아도 이 기기는 로그아웃 상태다', () async {
await signedIn();
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.offline);
await notifier().signOut();
expect(current(), const SignedOut());
expect(h.notifier.shownCodes, isEmpty);
});
});
group('제한 다시 맞추기 — 노티파이어의 갈래', () {
Future<void> signedIn() async {
await start(refreshToken: 'refresh-1');
refreshGives(2);
h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()));
expect(await settled(), isA<SignedIn>());
}
int refreshCalls() => h.server.received('POST', ApiPaths.appRefresh).length;
test('me 가 401(액세스 토큰이 끝남)이면 갱신해 보고, 새 토큰에 제한이 없으면 renewed', () async {
await signedIn();
h.server.on('GET', ApiPaths.me, (RequestOptions options) {
return bearerTokenOf(options) == 'access-2'
? FakeResponse.fail(401, 'AUTH_001')
: FakeResponse.ok(sessionJson());
});
refreshGives(3);
expect(await notifier().recheck(), RestrictionCheck.renewed);
expect(refreshCalls(), 2);
expect(h.store.accessToken, 'access-3');
expect(current(), isA<SignedIn>());
expect(restrictionNow(), isNull);
});
test('me 401 뒤 갱신도 401 이면 stale — 세션 끝이 로그인 화면으로 옮긴다', () async {
await signedIn();
h.server.on(
'GET',
ApiPaths.me,
(_) => FakeResponse.fail(401, 'AUTH_001'),
);
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.fail(401, 'AUTH_001'),
);
expect(await notifier().recheck(), RestrictionCheck.stale);
await pumpEventsUntil(() => current() is SignedOut, reason: '세션 끝');
expect(current(), const SignedOut(reason: SignOutReason.expired));
expect(h.store.hasSession, isFalse);
});
test('me 가 장애(503)면 unknown — 상태와 토큰은 그대로', () async {
await signedIn();
h.server.on(
'GET',
ApiPaths.me,
(_) => FakeResponse.fail(503, 'MAIL_001'),
);
expect(await notifier().recheck(), RestrictionCheck.unknown);
expect(current(), isA<SignedIn>());
expect(refreshCalls(), 1);
expect(h.store.hasSession, isTrue);
});
});
group('저장 · 로그아웃의 경합', () {
test('로그인 토큰을 저장하는 사이 로그아웃하면 토큰이 남지 않고 그 세션도 끝낸다', () async {
await start();
await settled();
h.server.on('POST', ApiPaths.appLogin, (_) => signedInWith(1));
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
final Completer<void> gate = Completer<void>();
h.secure.writeGate = gate;
final Future<void> signIn = notifier().signIn(
email: 'ara.kim@arasystem.it.kr',
password: 'pw',
);
await pumpEventsUntil(
() => h.secure.writeCalls == 1,
reason: '토큰 쓰기가 시작',
);
final Future<void> signOut = notifier().signOut();
gate.complete();
await signOut;
await signIn;
expect(current(), const SignedOut());
expect(h.store.hasSession, isFalse);
expect(h.store.accessToken, isNull);
expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse);
await pumpEventsUntil(() => logouts().isNotEmpty, reason: '서버 세션 끝내기');
expect(
logouts().every(
(RequestOptions r) =>
(r.data! as Map<String, Object?>)['refreshToken'] == 'refresh-1',
),
isTrue,
);
});
test(
'로그인 토큰을 보안 저장소에 두지 못하면 이 기기의 토큰을 지우고 그 세션을 끝낸 뒤 SYS_001 로 던진다',
() async {
await start();
await settled();
h.server.on('POST', ApiPaths.appLogin, (_) => signedInWith(1));
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
h.secure.failWrites = true;
final Object error = await errorOf(
notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw'),
);
expect(error, isA<DioException>());
expect(apiErrorOf(error).code, 'SYS_001');
expect(current(), const SignedOut());
expect(h.store.hasSession, isFalse);
expect(h.store.accessToken, isNull);
await pumpEventsUntil(() => logouts().isNotEmpty, reason: '서버 세션 끝내기');
expect(bearerTokenOf(logouts().single), 'access-1');
},
);
test('로그아웃은 토큰과 상태를 먼저 지우고, 알림 토큰 지우기를 기다리지 않는다', () async {
await start(refreshToken: 'refresh-1');
refreshGives(2);
h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()));
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
expect(await settled(), isA<SignedIn>());
// 알림 토큰 지우기가 끝나지 않아도 로그아웃은 끝난다(짧은 한도 · 실패는 삼킨다)
h.push.unregisterGate = Completer<void>();
await notifier().signOut();
expect(current(), const SignedOut());
expect(h.store.hasSession, isFalse);
expect(h.push.unregistered, 1);
expect(h.push.hadSessionAtUnregister, isFalse);
expect(logouts(), hasLength(1));
});
test('되살리기가 장애로 멈춘 채 [다른 계정으로 로그인]을 눌러도 남은 리프레시 토큰을 지운다', () async {
await start(refreshToken: 'refresh-1');
h.server.on('POST', ApiPaths.appRefresh, (_) => FakeResponse.offline);
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
expect(await settled(), isNull);
await notifier().switchAccount();
expect(current(), const SignedOut(reason: SignOutReason.switchAccount));
expect(h.store.hasSession, isFalse);
expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse);
expect(logouts().single.data, <String, Object?>{
'refreshToken': 'refresh-1',
});
});
test('되살리는 사이 로그아웃하면 늦게 온 되살리기 결과가 로그아웃을 덮지 않는다', () async {
final Completer<void> release = Completer<void>();
await start(refreshToken: 'refresh-1');
refreshGives(2);
h.server.on('GET', ApiPaths.me, (_) async {
await release.future;
return FakeResponse.ok(sessionJson());
});
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
await pumpEventsUntil(
() => h.server.received('GET', ApiPaths.me).isNotEmpty,
reason: '되살리기의 me 가 서버에 닿기',
);
await notifier().signOut();
release.complete();
expect(await settled(), const SignedOut());
expect(current(), const SignedOut());
expect(h.store.hasSession, isFalse);
});
});
}
+358
View File
@@ -0,0 +1,358 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:arasystem_app/core/api/api_config.dart';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/api/dio_client.dart';
import 'package:arasystem_app/core/api/error_notifier.dart';
import 'package:arasystem_app/core/api/session_events.dart';
import 'package:arasystem_app/core/push/push_registration.dart';
import 'package:arasystem_app/core/storage/auth_tokens.dart';
import 'package:arasystem_app/core/storage/first_run_marker.dart';
import 'package:arasystem_app/core/storage/secure_store.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// 시험의 기준 시각 (UTC)
final DateTime kTestNow = DateTime.utc(2026, 11, 5, 3);
/// 시험의 User-Agent
const String kTestUserAgent = 'ARASYSTEM-App/1.0.0 (Android 15; Pixel 8)';
/// 손으로 돌리는 시계 — 고정 대기 없이 시간을 넘긴다
class ManualClock {
ManualClock([DateTime? start]) : now = start ?? kTestNow;
DateTime now;
DateTime call() => now;
void advance(Duration duration) => now = now.add(duration);
}
/// 메모리 보안 저장소 — [failReads] · [failWrites] · [failDeletes] 를 켜면 그 일을 `PlatformException` 으로 실패한다.
/// [readGate] · [writeGate] 를 두면 그 일이 그 Completer 가 끝날 때까지 기다린다(경합 시험).
class MemorySecureStore implements SecureStore {
final Map<String, String> values = <String, String>{};
int deleteAllCount = 0;
int writeCount = 0;
/// 쓰기를 부른 수(문을 기다리기 전에 센다)
int writeCalls = 0;
/// 읽기에서 던질 뜻밖의 예외(`PlatformException` 이 아닌 것) — 인터셉터의 처리기 지키기 시험
Object? throwOnRead;
bool failReads = false;
bool failWrites = false;
bool failDeletes = false;
Completer<void>? readGate;
Completer<void>? writeGate;
static PlatformException _failure(String what) =>
PlatformException(code: 'test', message: '$what 실패(시험)');
/// 읽기를 부른 수(문을 기다리기 전에 센다)
int readCalls = 0;
@override
Future<String?> read(String key) async {
readCalls++;
await readGate?.future;
final Object? unexpected = throwOnRead;
if (unexpected != null) throw unexpected;
if (failReads) throw _failure('읽기');
return values[key];
}
@override
Future<void> write(String key, String value) async {
writeCalls++;
await writeGate?.future;
writeCount++;
if (failWrites) throw _failure('쓰기');
values[key] = value;
}
@override
Future<void> delete(String key) async {
if (failDeletes) throw _failure('지우기');
values.remove(key);
}
@override
Future<void> deleteAll() async {
deleteAllCount++;
values.clear();
}
}
/// 메모리 첫 실행 표시
class MemoryFirstRunMarker implements FirstRunMarker {
MemoryFirstRunMarker({this.marked = true});
bool marked;
@override
Future<bool> isMarked() async => marked;
@override
Future<void> mark() async => marked = true;
}
/// 푸시 기기 등록을 적어 두는 가짜 — 지울 때 세션이 남아 있었는지도 적는다
class RecordingPushRegistrar implements PushRegistrar {
RecordingPushRegistrar(this._store);
final TokenStore _store;
int registered = 0;
int unregistered = 0;
bool? hadSessionAtUnregister;
/// 두면 지우기가 이것이 끝날 때까지 기다린다
Completer<void>? unregisterGate;
@override
Future<void> register() async => registered++;
@override
Future<void> unregister() async {
unregistered++;
hadSessionAtUnregister = _store.hasSession;
await unregisterGate?.future;
}
}
/// 알림을 띄우지 않고 적어 두는 알림 — 겹침 거르기([ErrorNotifier.show])는 그대로 탄다
class RecordingNotifier extends ErrorNotifier {
RecordingNotifier(ManualClock clock)
: super(
messengerKey: GlobalKey<ScaffoldMessengerState>(),
clock: clock.call,
);
final List<String> shownCodes = <String>[];
@override
void display(ApiException error) => shownCodes.add(error.code);
}
/// 가짜 응답
class FakeResponse {
const FakeResponse(this.status, this.body, {this.rawBody});
/// 표준 성공 `{ success: true, data }`
factory FakeResponse.ok([Object? data]) =>
FakeResponse(200, <String, Object?>{'success': true, 'data': data});
/// 표준 실패 `{ success: false, error: { code, message, traceId } }`
factory FakeResponse.fail(int status, String code) =>
FakeResponse(status, <String, Object?>{
'success': false,
'error': <String, Object?>{
'code': code,
'message': '',
'traceId': 'trace-$code',
},
});
/// 응답 없음(연결 끊김)
static const FakeResponse offline = FakeResponse(-1, null);
final int status;
final Object? body;
/// JSON 이 아닌 그대로의 본문 (있으면 [body] 대신)
final String? rawBody;
}
typedef FakeHandler = FutureOr<FakeResponse> Function(RequestOptions options);
/// 가짜 서버 — 경로마다 처리기를 둔다 (HttpClientAdapter)
class FakeServer implements HttpClientAdapter {
final Map<String, FakeHandler> _routes = <String, FakeHandler>{};
/// 받은 요청 차례대로
final List<RequestOptions> requests = <RequestOptions>[];
/// 응답의 `Date` 머리(서버 시각) — null 이면 싣지 않는다
DateTime? serverTime;
void on(String method, String path, FakeHandler handler) {
_routes['$method $path'] = handler;
}
/// [method] [path] 로 받은 요청들
List<RequestOptions> received(String method, String path) => requests
.where((RequestOptions r) => r.method == method && r.path == path)
.toList();
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
// 받은 그때의 모습을 남긴다 — 인터셉터가 실패를 내보내며 토큰 · 비밀 본문을 지워도 시험이 볼 수 있게
requests.add(options.copyWith());
final FakeHandler? handler = _routes['${options.method} ${options.path}'];
final FakeResponse response = handler == null
? FakeResponse.fail(404, 'RES_001')
: await handler(options);
if (response.status < 0) {
throw DioException.connectionError(
requestOptions: options,
reason: 'offline',
);
}
final DateTime? date = serverTime;
return ResponseBody.fromString(
response.rawBody ?? jsonEncode(response.body),
response.status,
headers: <String, List<String>>{
Headers.contentTypeHeader: <String>[Headers.jsonContentType],
if (date != null)
HttpHeaders.dateHeader: <String>[HttpDate.format(date)],
},
);
}
@override
void close({bool force = false}) {}
}
/// 요청의 Bearer 토큰 (없으면 null)
String? bearerTokenOf(RequestOptions options) {
final Object? header = options.headers['Authorization'];
if (header is! String || !header.startsWith('Bearer ')) return null;
return header.substring('Bearer '.length);
}
/// 토큰 한 벌 — [n] 번째 발급
AuthTokens tokensFor(
int n, {
DateTime? now,
Duration accessLife = const Duration(hours: 1),
}) {
final DateTime base = now ?? kTestNow;
return AuthTokens(
accessToken: 'access-$n',
accessExpiresAt: base.add(accessLife),
refreshToken: 'refresh-$n',
refreshExpiresAt: base.add(const Duration(days: 30)),
);
}
/// 응답의 `tokens` 칸 꼴
Map<String, Object?> tokensJson(int n, {DateTime? now}) {
final AuthTokens tokens = tokensFor(n, now: now);
return <String, Object?>{
'accessToken': tokens.accessToken,
'accessExpiresAt': tokens.accessExpiresAt.toIso8601String(),
'refreshToken': tokens.refreshToken,
'refreshExpiresAt': tokens.refreshExpiresAt.toIso8601String(),
};
}
/// 직원 · 세션 칸
Map<String, Object?> sessionJson({String? restriction, String? deadline}) =>
<String, Object?>{
'email': 'ara.kim@arasystem.it.kr',
'name': '김아라',
'restriction': restriction,
'twoFactorDeadline': deadline,
};
/// 네트워크 층을 한데 묶은 시험대
class ApiHarness {
ApiHarness({bool firstRunMarked = true})
: firstRun = MemoryFirstRunMarker(marked: firstRunMarked);
final FakeServer server = FakeServer();
final MemorySecureStore secure = MemorySecureStore();
final MemoryFirstRunMarker firstRun;
final ManualClock clock = ManualClock();
final SessionEventBus events = SessionEventBus();
final List<SessionEvent> emitted = <SessionEvent>[];
/// `AUTH_010` 을 다시 맞춰 달라는 알림에 이 시험대가 돌려줄 결과 (세션 노티파이어 대신)
RestrictionCheck recheckAnswer = RestrictionCheck.unknown;
/// 있으면 [recheckAnswer] 대신 이것으로 답한다 — 노티파이어처럼 새 토큰을 받아 두고 답하는 흉내
Future<RestrictionCheck> Function()? onRecheck;
/// 제한 다시 맞추기를 몇 번 물었나
int get recheckCount => emitted.whereType<SessionRecheckNeeded>().length;
late final TokenStore store = TokenStore(
secure: secure,
firstRun: firstRun,
clock: clock.call,
);
late final RecordingNotifier notifier = RecordingNotifier(clock);
late final DioClient client = DioClient.create(
config: const ApiConfig(baseUrl: 'https://portal.test/api'),
tokenStore: store,
events: events,
notifier: notifier,
userAgent: kTestUserAgent,
clock: clock.call,
httpClientAdapter: server,
);
Dio get dio => client.dio;
/// 준비 — 첫 실행 처리 · 세션 알림 적기(제한 다시 맞추기에는 [recheckAnswer] 로 답한다)
Future<void> prepare() async {
events.stream.listen((SessionEvent event) async {
emitted.add(event);
if (event is! SessionRecheckNeeded) return;
final Future<RestrictionCheck> Function()? answer = onRecheck;
event.reply(answer == null ? recheckAnswer : await answer());
});
await store.prepare();
}
/// 로그인한 상태로 만든다 ([n] 번째 토큰)
Future<void> signIn([int n = 1]) => store.save(tokensFor(n, now: clock.now));
/// 앱을 새로 띄운 상태 — 보안 저장소에 리프레시 토큰만 있다
Future<void> coldStartWithRefreshToken(String refreshToken) async {
secure.values[kRefreshTokenKey] = refreshToken;
await store.prepare();
}
}
/// 실패에서 ApiException 을 꺼낸다
ApiException apiErrorOf(Object error) {
if (error is DioException && error.error is ApiException) {
return error.error! as ApiException;
}
if (error is ApiException) return error;
throw StateError('ApiException 이 아니다: $error');
}
/// [condition] 이 될 때까지 이벤트 차례를 넘긴다 — 고정 대기 없이, [maxTurns] 를 넘으면 실패한다
Future<void> pumpEventsUntil(
bool Function() condition, {
int maxTurns = 1000,
String reason = '조건',
}) async {
for (int turn = 0; turn < maxTurns; turn++) {
if (condition()) return;
await Future<void>.delayed(Duration.zero);
}
if (!condition()) throw StateError('$reason 이 $maxTurns 차례 안에 되지 않았다');
}
/// 미래가 실패하면 그 오류를 돌려준다(성공하면 시험 실패)
Future<Object> errorOf(Future<Object?> future) async {
try {
await future;
} on Object catch (error) {
return error;
}
throw StateError('실패해야 하는데 성공했다');
}
@@ -0,0 +1,47 @@
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/push/push_registration.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:arasystem_app/core/storage/token_store_provider.dart';
import 'package:arasystem_app/core/utils/provider_retry.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart';
import 'fakes.dart';
/// 프로바이더 시험대 — 가짜 서버 · 메모리 저장소 · 손 시계로 앱의 프로바이더를 띄운다
class ProviderHarness {
ProviderHarness();
final FakeServer server = FakeServer();
final MemorySecureStore secure = MemorySecureStore();
final MemoryFirstRunMarker firstRun = MemoryFirstRunMarker();
final ManualClock clock = ManualClock();
late final TokenStore store = TokenStore(
secure: secure,
firstRun: firstRun,
clock: clock.call,
);
late final RecordingNotifier notifier = RecordingNotifier(clock);
late final RecordingPushRegistrar push = RecordingPushRegistrar(store);
/// ProviderScope · ProviderContainer 에 넣을 덮어쓰기
List<Override> get overrides => <Override>[
tokenStoreProvider.overrideWithValue(store),
httpClientAdapterProvider.overrideWithValue(server),
clockProvider.overrideWithValue(clock.call),
errorNotifierProvider.overrideWithValue(notifier),
userAgentProvider.overrideWithValue(kTestUserAgent),
pushRegistrarProvider.overrideWithValue(push),
];
/// 앱과 같게 프로바이더 다시 하기를 끈 컨테이너
ProviderContainer container() =>
ProviderContainer(overrides: overrides, retry: noAutomaticRetry);
/// 보안 저장소에 리프레시 토큰을 두고(앱을 새로 띄운 상태) 준비한다
Future<void> prepare({String? refreshToken}) async {
if (refreshToken != null) secure.values[kRefreshTokenKey] = refreshToken;
await store.prepare();
}
}