generated from ttipo/project-base
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user