generated from ttipo/project-base
feat(flutter): 로그인 네 단계 화면을 만든다 (앱 1단계 — App1Login)
주소 · 비밀번호 → 인증 앱 코드 → 새 비밀번호 정하기(임시 비밀번호) → 2단계 켜기(필수일 때)를 세션 노티파이어의 상태로 고른다. 첫 단계만 큰 브랜드 띠, 그다음은 짙은 띠에 [다른 계정으로 로그인]. - 2단계 켜기는 같은 휴대폰이라 QR 대신 [인증 앱에 추가](otpauth — url_launcher 6.3.3, canLaunchUrl 은 주소를 로그에 남겨 쓰지 않는다) · 키 [복사] · 코드 칸 - 실패는 칸 아래 · 위쪽 상자 · 전역 알림으로 웹 로그인 틀과 같게(REQ_001 은 코드 · 제한 단계에서 상자), 칸의 안내와 오류를 함께, 세션 끝은 로그인 화면 상자만 - 비밀번호 · 코드 · 2단계 키는 화면 메모리와 노티파이어의 10분 기억에만(두 제한 단계에서만 쥔다), 비밀 요청의 실패 예외는 본문을 비운다 — 시험이 모든 프로바이더 값에 비밀이 없음을 본다 - 제한 단계를 보내는 동안 [다른 계정으로 로그인]을 막는다, 주소 칸만 자판 추천(@ 자판) - 한글 낱말 단위 줄바꿈(KeepAllText), 시험 227건 리뷰 반영: 모르는 제한에서 비밀번호를 쥐지 않음, REQ_001 자리, 안내 · 오류 함께, 주소 칸 자판, 계약 테스트 목록(AUTH_005 · AUTH_008), AUTH_002 를 두 번 알리지 않음. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,9 +5,11 @@ 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/login_texts.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/auth/presentation/pages/login_page.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';
|
||||
@@ -17,6 +19,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'support/fakes.dart';
|
||||
import 'support/login_fakes.dart';
|
||||
import 'support/provider_harness.dart';
|
||||
|
||||
void main() {
|
||||
@@ -43,13 +46,12 @@ void main() {
|
||||
|
||||
await pumpApp(tester);
|
||||
|
||||
expect(find.text(ScreenTitles.login), findsOneWidget);
|
||||
expect(find.byType(LoginPage), findsOneWidget);
|
||||
expect(findCopy(LoginTexts.subtitle), 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),
|
||||
);
|
||||
final BuildContext context = tester.element(find.byType(LoginPage));
|
||||
expect(Localizations.localeOf(context).languageCode, 'ko');
|
||||
});
|
||||
|
||||
@@ -72,7 +74,7 @@ void main() {
|
||||
|
||||
await pumpApp(tester);
|
||||
|
||||
expect(find.text(ScreenTitles.passwordChange), findsOneWidget);
|
||||
expect(findCopy(ScreenTitles.passwordChange), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('되살린 세션에 제한이 없으면 메일 화면이다', (WidgetTester tester) async {
|
||||
@@ -227,7 +229,7 @@ void main() {
|
||||
await tester.tap(page(SettingsTexts.signOut));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(ScreenTitles.login), findsOneWidget);
|
||||
expect(find.byType(LoginPage), findsOneWidget);
|
||||
expect(router.routerDelegate.currentConfiguration.uri.path, '/login');
|
||||
expect(h.server.received('POST', ApiPaths.appLogout), hasLength(1));
|
||||
});
|
||||
|
||||
@@ -115,25 +115,29 @@ void main() {
|
||||
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');
|
||||
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)),
|
||||
);
|
||||
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);
|
||||
});
|
||||
expect(error.code, 'AUTH_002');
|
||||
expect(api.server.received('GET', _mailState), isEmpty);
|
||||
// 세션 끝은 늘 로그인 화면으로 가고 그 화면의 위쪽 상자가 같은 사전 문구로 알린다 — 스낵바는 띄우지 않는다
|
||||
expect(api.notifier.shownCodes, isEmpty);
|
||||
expect(api.emitted.whereType<SessionEnded>(), hasLength(1));
|
||||
expect(api.store.hasSession, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('미리 갱신이 장애면 원래 요청을 보내지 않고 그 코드로 알리며 세션은 둔다', () async {
|
||||
await api.coldStartWithRefreshToken('refresh-1');
|
||||
@@ -262,7 +266,7 @@ void main() {
|
||||
expect(second.files.single.value.filename, 'a.txt');
|
||||
});
|
||||
|
||||
test('갱신이 401 이면 토큰을 지우고 AUTH_002 로 알리며 세션 끝을 알린다', () async {
|
||||
test('갱신이 401 이면 토큰을 지우고 AUTH_002 로 끝내며 세션 끝을 알린다(스낵바 없음)', () async {
|
||||
await api.signIn();
|
||||
api.server.on(
|
||||
'POST',
|
||||
@@ -280,12 +284,12 @@ void main() {
|
||||
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.notifier.shownCodes, isEmpty);
|
||||
// 원래 요청은 다시 보내지 않았다
|
||||
expect(api.server.received('GET', _mailState), hasLength(1));
|
||||
});
|
||||
|
||||
test('동시에 세션이 끝난 요청들의 AUTH_002 알림은 한 번만 뜬다', () async {
|
||||
test('동시에 세션이 끝난 요청들은 갱신 한 번으로 모두 AUTH_002 — 스낵바는 띄우지 않는다', () async {
|
||||
await api.signIn();
|
||||
api.server.on(
|
||||
'POST',
|
||||
@@ -304,7 +308,7 @@ void main() {
|
||||
'AUTH_002',
|
||||
]);
|
||||
expect(refreshCount(), 1);
|
||||
expect(api.notifier.shownCodes, <String>['AUTH_002']);
|
||||
expect(api.notifier.shownCodes, isEmpty);
|
||||
});
|
||||
|
||||
test('갱신이 일시 장애(503 · 연결 끊김)면 세션을 두고 그 코드로 알린다', () async {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'package:arasystem_app/core/utils/display_text.dart';
|
||||
import 'package:arasystem_app/core/utils/korean_text.dart';
|
||||
import 'package:arasystem_app/core/utils/otp_code.dart';
|
||||
import 'package:arasystem_app/core/utils/password_rules.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('인증 앱 코드 칸 다듬기', () {
|
||||
test('숫자만 남기고 6자리에서 자른다 — 붙여 넣은 띄어쓰기 · 하이픈은 지운다', () {
|
||||
expect(normalizeOtpInput('123 456'), '123456');
|
||||
expect(normalizeOtpInput('123-456'), '123456');
|
||||
expect(normalizeOtpInput(' 12a3b4 56 789'), '123456');
|
||||
expect(normalizeOtpInput(''), '');
|
||||
});
|
||||
|
||||
test('전각 숫자도 반각으로 받는다(웹의 NFKC 와 같다)', () {
|
||||
expect(normalizeOtpInput('123 456'), '123456');
|
||||
});
|
||||
|
||||
test('숫자 6자리일 때만 다 넣은 것이다', () {
|
||||
expect(isCompleteOtp('123456'), isTrue);
|
||||
expect(isCompleteOtp('12345'), isFalse);
|
||||
expect(isCompleteOtp('1234567'), isFalse);
|
||||
expect(isCompleteOtp('12345a'), isFalse);
|
||||
expect(isCompleteOtp(''), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('새 비밀번호 규칙 (웹 password-change.ts 와 같다)', () {
|
||||
test('8자 미만이거나 지금 비밀번호와 같으면 안내 글이 오류가 된다', () {
|
||||
expect(
|
||||
passwordChangeProblems(
|
||||
current: 'temp-1234',
|
||||
next: 'short',
|
||||
confirm: 'short',
|
||||
).next,
|
||||
NewPasswordProblem.rule,
|
||||
);
|
||||
expect(
|
||||
passwordChangeProblems(
|
||||
current: 'temp-1234',
|
||||
next: 'temp-1234',
|
||||
confirm: 'temp-1234',
|
||||
).next,
|
||||
NewPasswordProblem.rule,
|
||||
);
|
||||
});
|
||||
|
||||
test('128자를 넘으면 tooLong, 확인 칸이 다르면 mismatch, 지금 비밀번호가 비면 missing', () {
|
||||
final String long = 'a' * (kPasswordMaxLength + 1);
|
||||
final PasswordChangeProblems problems = passwordChangeProblems(
|
||||
current: '',
|
||||
next: long,
|
||||
confirm: 'other',
|
||||
);
|
||||
expect(problems.next, NewPasswordProblem.tooLong);
|
||||
expect(problems.confirmMismatch, isTrue);
|
||||
expect(problems.currentMissing, isTrue);
|
||||
expect(problems.hasAny, isTrue);
|
||||
});
|
||||
|
||||
test('규칙에 맞으면 문제가 없다 — 공백도 비밀번호의 글자다', () {
|
||||
final PasswordChangeProblems problems = passwordChangeProblems(
|
||||
current: 'temp-1234',
|
||||
next: ' new pass ',
|
||||
confirm: ' new pass ',
|
||||
);
|
||||
expect(problems.hasAny, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('문의 연락처 한 줄 (웹 support-contact.ts 와 같다)', () {
|
||||
test('줄바꿈 · 연속 공백은 띄어쓰기 하나, 앞뒤 공백은 지운다', () {
|
||||
expect(
|
||||
supportContactTextOf(' IT 담당\n홍길동 02-000-0000 '),
|
||||
'IT 담당 홍길동 02-000-0000',
|
||||
);
|
||||
});
|
||||
|
||||
test('빈 글 · 글이 아닌 값은 null(정하지 않음), 200자에서 자른다', () {
|
||||
expect(supportContactTextOf(null), isNull);
|
||||
expect(supportContactTextOf(' '), isNull);
|
||||
expect(supportContactTextOf(3), isNull);
|
||||
expect(
|
||||
supportContactTextOf('가' * 250),
|
||||
hasLength(kSupportContactMaxLength),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('2단계 인증 키 줄', () {
|
||||
test('4자씩 끊어 한 줄에 4묶음 — 32자 키는 두 줄이다', () {
|
||||
expect(keyLines('K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS'), <String>[
|
||||
'K7QF 2M4X HB6R TZ3N',
|
||||
'PW5C YL7D JQ2V MGAS',
|
||||
]);
|
||||
});
|
||||
|
||||
test('길이가 4의 배수가 아니어도 남은 글자를 잃지 않는다', () {
|
||||
expect(keyLines('ABCDEFGHIJ'), <String>['ABCD EFGH IJ']);
|
||||
expect(keyLines(''), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('한국어 줄 나눔 (keep-all)', () {
|
||||
// WORD JOINER(U+2060) — 보이지 않는 글자라 코드 포인트로 만든다
|
||||
final String joiner = String.fromCharCode(0x2060);
|
||||
|
||||
test('한글 낱말 안에 줄 나눔 막기를 넣고 띄어쓰기는 그대로 둔다', () {
|
||||
final String first = <String>['자', '동', '으', '로'].join(joiner);
|
||||
final String second = <String>['맞', '춰'].join(joiner);
|
||||
expect(keepAllWordBreak('자동으로 맞춰'), '$first $second');
|
||||
});
|
||||
|
||||
test('한글과 붙은 영문 · 기호도 한 낱말로 남는다', () {
|
||||
expect(
|
||||
keepAllWordBreak('Outlook이나'),
|
||||
<String>['Outlook', '이', '나'].join(joiner),
|
||||
);
|
||||
expect(
|
||||
keepAllWordBreak('[설정]에서'),
|
||||
<String>['[', '설', '정', ']', '에', '서'].join(joiner),
|
||||
);
|
||||
});
|
||||
|
||||
test('줄 나눔 막기를 지우면 원래 글이다', () {
|
||||
const String text = 'Outlook이나 휴대폰 기본 메일 앱 같은 다른 메일 프로그램은';
|
||||
expect(keepAllWordBreak(text).replaceAll(joiner, ''), text);
|
||||
});
|
||||
|
||||
test('한글이 없는 낱말 · 이모지 옆에는 넣지 않는다(글자 묶음을 가르지 않게)', () {
|
||||
expect(keepAllWordBreak('02-000-0000 @it'), '02-000-0000 @it');
|
||||
expect(keepAllWordBreak('가👍나'), '가👍나');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -20,7 +20,9 @@ void main() {
|
||||
|
||||
test('클라이언트 로직이 참조하는 코드가 사전에 존재한다', () {
|
||||
// 상태 코드 역매핑(api_error_mapper) · 세션 만료 승격 · AUTH_010 다시 확인(auth_interceptor) ·
|
||||
// 로그인 화면이 칸에 보이는 실패(auth_repository 의 kLoginInlineCodes)가 이 코드들을 직접 사용한다.
|
||||
// 로그인 화면이 칸에 보이는 실패(auth_repository 의 kLoginInlineCodes) ·
|
||||
// 제한 단계(새 비밀번호 · 2단계 켜기 — kPasswordChangeInlineCodes · kTwoFactorSetupInlineCodes 의 AUTH_005 · AUTH_008)가
|
||||
// 이 코드들을 직접 사용한다.
|
||||
//
|
||||
// ⚠️ 전체 목록을 비교하지 않는 이유: 세 플랫폼의 사전은 backend 레지스트리에서
|
||||
// 생성되므로 목록 일치는 구조적으로 보장되고, CI 가 드리프트를 검사한다.
|
||||
@@ -32,8 +34,10 @@ void main() {
|
||||
'AUTH_002',
|
||||
'AUTH_003',
|
||||
'AUTH_004',
|
||||
'AUTH_005',
|
||||
'AUTH_006',
|
||||
'AUTH_007',
|
||||
'AUTH_008',
|
||||
'AUTH_010',
|
||||
'VAL_001',
|
||||
'RES_001',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
import 'package:arasystem_app/features/auth/presentation/providers/login_secret.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../support/fakes.dart';
|
||||
|
||||
void main() {
|
||||
group('로그인 때 넣은 비밀번호 쥐기', () {
|
||||
test('쥐고 있는지를 알린다 — 값(비밀번호)이 아니라 참 · 거짓만', () {
|
||||
final ManualClock clock = ManualClock();
|
||||
final LoginSecret secret = LoginSecret(clock: clock.call);
|
||||
final List<bool> seen = <bool>[];
|
||||
secret.held.addListener(() => seen.add(secret.held.value));
|
||||
|
||||
secret.hold('ara.kim@arasystem.it.kr', 'temp-pw');
|
||||
secret.clear();
|
||||
|
||||
expect(seen, <bool>[true, false]);
|
||||
expect(secret.read(), isNull);
|
||||
});
|
||||
|
||||
test('10분이 지나면 읽을 때 시계로 다시 보고 지운다 — 쥐고 있지 않다고 알린다', () {
|
||||
final ManualClock clock = ManualClock();
|
||||
final LoginSecret secret = LoginSecret(clock: clock.call);
|
||||
secret.hold('ara.kim@arasystem.it.kr', 'temp-pw');
|
||||
|
||||
clock.advance(const Duration(minutes: 9, seconds: 59));
|
||||
expect(secret.read()?.password, 'temp-pw');
|
||||
|
||||
clock.advance(const Duration(seconds: 1));
|
||||
expect(secret.read(), isNull);
|
||||
expect(secret.held.value, isFalse);
|
||||
expect(secret.isHeld, isFalse);
|
||||
});
|
||||
|
||||
test('서버가 다시 확인하면(renew) 10분을 그때부터 다시 센다', () {
|
||||
final ManualClock clock = ManualClock();
|
||||
final LoginSecret secret = LoginSecret(clock: clock.call);
|
||||
secret.hold('ara.kim@arasystem.it.kr', 'temp-pw');
|
||||
|
||||
clock.advance(const Duration(minutes: 8));
|
||||
secret.renew();
|
||||
clock.advance(const Duration(minutes: 8));
|
||||
|
||||
expect(secret.read()?.password, 'temp-pw');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
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/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/login_fakes.dart';
|
||||
import '../../support/provider_harness.dart';
|
||||
|
||||
/// 세션 노티파이어의 코드 단계 · 제한 단계(새 비밀번호 · 2단계 켜기 · 다음으로 넘기기) — 계약 5 · 6절
|
||||
void main() {
|
||||
late ProviderHarness h;
|
||||
late ProviderContainer container;
|
||||
|
||||
Future<void> start() async {
|
||||
await h.prepare();
|
||||
container = h.container();
|
||||
addTearDown(container.dispose);
|
||||
container.listen(sessionProvider, (_, _) {});
|
||||
await container.read(sessionProvider.future);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Map<String, Object?> bodyOf(RequestOptions options) =>
|
||||
options.data! as Map<String, Object?>;
|
||||
|
||||
/// 제한된 세션으로 로그인한다 — 코드 단계를 거치면 [viaCode]
|
||||
Future<void> signInRestricted(
|
||||
String restriction, {
|
||||
bool viaCode = false,
|
||||
}) async {
|
||||
h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) {
|
||||
if (viaCode && bodyOf(options)['otpCode'] == null) {
|
||||
return codeStepResponse();
|
||||
}
|
||||
return signedInResponse(1, now: h.clock.now, restriction: restriction);
|
||||
});
|
||||
await notifier().signIn(email: kTestEmail, password: 'temp-pw-1');
|
||||
if (viaCode) await notifier().submitCode('123456');
|
||||
expect(current(), isA<SignedIn>());
|
||||
}
|
||||
|
||||
setUp(() => h = ProviderHarness());
|
||||
|
||||
group('코드 단계', () {
|
||||
test('코드를 실었는데도 코드를 묻으면 AUTH_006 으로 던지고 코드 단계에 남는다', () async {
|
||||
await start();
|
||||
h.server.on('POST', ApiPaths.appLogin, (_) => codeStepResponse());
|
||||
await notifier().signIn(email: kTestEmail, password: 'pw');
|
||||
|
||||
final Object error = await errorOf(notifier().submitCode('123456'));
|
||||
|
||||
expect(apiErrorOf(error).code, 'AUTH_006');
|
||||
expect(current(), const CodeRequired(kTestEmail));
|
||||
expect(notifier().heldLogin?.password, 'pw');
|
||||
});
|
||||
|
||||
test('그사이 비밀번호가 바뀌었으면(AUTH_004) 주소를 남긴 첫 단계로 돌아간다', () async {
|
||||
await start();
|
||||
h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) {
|
||||
return bodyOf(options)['otpCode'] == null
|
||||
? codeStepResponse()
|
||||
: FakeResponse.fail(401, 'AUTH_004');
|
||||
});
|
||||
await notifier().signIn(email: kTestEmail, password: 'pw');
|
||||
|
||||
final Object error = await errorOf(notifier().submitCode('123456'));
|
||||
|
||||
expect(apiErrorOf(error).code, 'AUTH_004');
|
||||
expect(
|
||||
current(),
|
||||
const SignedOut(
|
||||
reason: SignOutReason.passwordRejected,
|
||||
email: kTestEmail,
|
||||
),
|
||||
);
|
||||
expect(notifier().heldLogin, isNull);
|
||||
// 칸에 보이는 실패라 알리지 않는다
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
});
|
||||
|
||||
test('코드 단계를 거쳐 로그인했는지 기억한다 — 새 로그인 · 다른 계정이면 지운다', () async {
|
||||
await start();
|
||||
await signInRestricted('passwordChange', viaCode: true);
|
||||
expect(notifier().passedCodeStep, isTrue);
|
||||
|
||||
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
|
||||
await notifier().switchAccount();
|
||||
expect(notifier().passedCodeStep, isFalse);
|
||||
|
||||
await signInRestricted('passwordChange');
|
||||
expect(notifier().passedCodeStep, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('새 비밀번호 단계 (changePassword)', () {
|
||||
test('쥔 임시 비밀번호를 지금 비밀번호로 보내고, 바뀌면 새 비밀번호를 쥔다', () async {
|
||||
await start();
|
||||
await signInRestricted('passwordChange', viaCode: true);
|
||||
h.server.on('PUT', ApiPaths.password, (_) => FakeResponse.ok());
|
||||
|
||||
final RestrictedStepResult result = await notifier().changePassword(
|
||||
newPassword: 'new-pw-1234',
|
||||
otpCode: '654321',
|
||||
);
|
||||
|
||||
expect(result, RestrictedStepResult.done);
|
||||
final RequestOptions sent = h.server
|
||||
.received('PUT', ApiPaths.password)
|
||||
.single;
|
||||
expect(bodyOf(sent), <String, Object?>{
|
||||
'currentPassword': 'temp-pw-1',
|
||||
'newPassword': 'new-pw-1234',
|
||||
'otpCode': '654321',
|
||||
});
|
||||
expect(bearerTokenOf(sent), 'access-1');
|
||||
// 서버가 엔진을 두 번 부른다 — 받기 한도 30초
|
||||
expect(sent.receiveTimeout, const Duration(seconds: 30));
|
||||
// 다음 단계(2단계 켜기)의 지금 비밀번호
|
||||
expect(notifier().heldLogin?.password, 'new-pw-1234');
|
||||
expect(notifier().heldLoginListenable.value, isTrue);
|
||||
});
|
||||
|
||||
test('쥔 비밀번호를 서버가 거절하면(AUTH_005) 지운다 — 실패 예외에는 본문이 없고 알리지 않는다', () async {
|
||||
await start();
|
||||
await signInRestricted('passwordChange');
|
||||
h.server.on(
|
||||
'PUT',
|
||||
ApiPaths.password,
|
||||
(_) => FakeResponse.fail(400, 'AUTH_005'),
|
||||
);
|
||||
|
||||
final Object error = await errorOf(
|
||||
notifier().changePassword(newPassword: 'new-pw-1234'),
|
||||
);
|
||||
|
||||
expect(apiErrorOf(error).code, 'AUTH_005');
|
||||
expect((error as DioException).requestOptions.data, isNull);
|
||||
expect(notifier().heldLogin, isNull);
|
||||
expect(notifier().heldLoginListenable.value, isFalse);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
});
|
||||
|
||||
test('쥔 비밀번호가 없고 칸의 값도 주지 않으면 보내지 않는다(needsPassword)', () async {
|
||||
await start();
|
||||
await signInRestricted('passwordChange');
|
||||
h.clock.advance(const Duration(minutes: 10));
|
||||
|
||||
final RestrictedStepResult result = await notifier().changePassword(
|
||||
newPassword: 'new-pw-1234',
|
||||
);
|
||||
|
||||
expect(result, RestrictedStepResult.needsPassword);
|
||||
expect(h.server.received('PUT', ApiPaths.password), isEmpty);
|
||||
});
|
||||
|
||||
test('칸에 넣은 임시 비밀번호로 보낼 수 있다(앱을 다시 열었다)', () async {
|
||||
await start();
|
||||
await signInRestricted('passwordChange');
|
||||
h.clock.advance(const Duration(minutes: 10));
|
||||
h.server.on('PUT', ApiPaths.password, (_) => FakeResponse.ok());
|
||||
|
||||
final RestrictedStepResult result = await notifier().changePassword(
|
||||
newPassword: 'new-pw-1234',
|
||||
currentPassword: 'typed-temp',
|
||||
);
|
||||
|
||||
expect(result, RestrictedStepResult.done);
|
||||
expect(
|
||||
bodyOf(h.server.received('PUT', ApiPaths.password).single),
|
||||
<String, Object?>{
|
||||
'currentPassword': 'typed-temp',
|
||||
'newPassword': 'new-pw-1234',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('보내는 사이 [다른 계정으로 로그인]하면 결과를 쓰지 않는다(stale) — 새 비밀번호를 쥐지 않는다', () async {
|
||||
await start();
|
||||
await signInRestricted('passwordChange');
|
||||
final Completer<void> release = Completer<void>();
|
||||
h.server.on('PUT', ApiPaths.password, (_) async {
|
||||
await release.future;
|
||||
return FakeResponse.ok();
|
||||
});
|
||||
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
|
||||
|
||||
final Future<RestrictedStepResult> changing = notifier().changePassword(
|
||||
newPassword: 'new-pw-1234',
|
||||
);
|
||||
await pumpEventsUntil(
|
||||
() => h.server.received('PUT', ApiPaths.password).isNotEmpty,
|
||||
reason: '비밀번호 바꾸기가 서버에 닿기',
|
||||
);
|
||||
await notifier().switchAccount();
|
||||
release.complete();
|
||||
|
||||
expect(await changing, RestrictedStepResult.stale);
|
||||
expect(notifier().heldLogin, isNull);
|
||||
expect(current(), const SignedOut(reason: SignOutReason.switchAccount));
|
||||
});
|
||||
});
|
||||
|
||||
group('2단계 켜기 (enableTwoFactor)', () {
|
||||
test('등록 id · 코드 · 쥔 비밀번호를 보내고, 켜면 쥔 비밀번호를 지운다', () async {
|
||||
await start();
|
||||
await signInRestricted('twoFactorSetup');
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.twoFactorActivation,
|
||||
(_) => twoFactorStatusResponse(enabled: true),
|
||||
);
|
||||
|
||||
final RestrictedStepResult result = await notifier().enableTwoFactor(
|
||||
enrollmentId: 'enroll-1',
|
||||
otpCode: '123456',
|
||||
);
|
||||
|
||||
expect(result, RestrictedStepResult.done);
|
||||
final RequestOptions sent = h.server
|
||||
.received('POST', ApiPaths.twoFactorActivation)
|
||||
.single;
|
||||
expect(bodyOf(sent), <String, Object?>{
|
||||
'enrollmentId': 'enroll-1',
|
||||
'otpCode': '123456',
|
||||
'currentPassword': 'temp-pw-1',
|
||||
});
|
||||
expect(sent.receiveTimeout, const Duration(seconds: 30));
|
||||
expect(notifier().heldLogin, isNull);
|
||||
});
|
||||
|
||||
test('쥔 비밀번호를 서버가 거절하면(AUTH_005) 지운다 — 화면이 지금 비밀번호 칸을 보인다', () async {
|
||||
await start();
|
||||
await signInRestricted('twoFactorSetup');
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.twoFactorActivation,
|
||||
(_) => FakeResponse.fail(400, 'AUTH_005'),
|
||||
);
|
||||
|
||||
final Object error = await errorOf(
|
||||
notifier().enableTwoFactor(enrollmentId: 'enroll-1', otpCode: '123456'),
|
||||
);
|
||||
|
||||
expect(apiErrorOf(error).code, 'AUTH_005');
|
||||
expect((error as DioException).requestOptions.data, isNull);
|
||||
expect(notifier().heldLoginListenable.value, isFalse);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
});
|
||||
|
||||
test('코드가 틀리면(AUTH_006) 쥔 비밀번호는 그대로다(코드만 다시 넣는다)', () async {
|
||||
await start();
|
||||
await signInRestricted('twoFactorSetup');
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.twoFactorActivation,
|
||||
(_) => FakeResponse.fail(400, 'AUTH_006'),
|
||||
);
|
||||
|
||||
await errorOf(
|
||||
notifier().enableTwoFactor(enrollmentId: 'enroll-1', otpCode: '000000'),
|
||||
);
|
||||
|
||||
expect(notifier().heldLogin?.password, 'temp-pw-1');
|
||||
});
|
||||
});
|
||||
|
||||
group('모르는 제한 — 로그인 때 넣은 비밀번호를 쥐지 않는다(웹 onSignedIn 과 같다)', () {
|
||||
test('모르는 제한으로 로그인하면 쓸 단계가 없어 쥐지 않는다', () async {
|
||||
await start();
|
||||
|
||||
await signInRestricted('somethingNew');
|
||||
|
||||
expect(restrictionNow(), SessionRestriction.unknown);
|
||||
expect(notifier().heldLogin, isNull);
|
||||
expect(notifier().heldLoginListenable.value, isFalse);
|
||||
});
|
||||
|
||||
test('갱신 응답으로 제한이 모르는 제한이 되면 쥔 비밀번호를 지운다', () async {
|
||||
await start();
|
||||
await signInRestricted('passwordChange');
|
||||
expect(notifier().heldLogin, isNotNull);
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.appRefresh,
|
||||
(_) =>
|
||||
refreshResponse(2, now: h.clock.now, restriction: 'somethingNew'),
|
||||
);
|
||||
|
||||
await container.read(authRepositoryProvider).refresh();
|
||||
await pumpEventsUntil(
|
||||
() => restrictionNow() == SessionRestriction.unknown,
|
||||
reason: '갱신 응답의 제한 반영',
|
||||
);
|
||||
|
||||
expect(notifier().heldLogin, isNull);
|
||||
expect(notifier().heldLoginListenable.value, isFalse);
|
||||
});
|
||||
|
||||
test('다시 맞추기(AUTH_010)로 모르는 제한이 되면 쥔 비밀번호를 지운다', () async {
|
||||
await start();
|
||||
await signInRestricted('twoFactorSetup');
|
||||
h.server.on(
|
||||
'GET',
|
||||
'/mailboxes',
|
||||
(_) => FakeResponse.fail(403, 'AUTH_010'),
|
||||
);
|
||||
h.server.on(
|
||||
'GET',
|
||||
ApiPaths.me,
|
||||
(_) => FakeResponse.ok(sessionJson(restriction: 'somethingNew')),
|
||||
);
|
||||
|
||||
await errorOf(container.read(dioProvider).get<Object?>('/mailboxes'));
|
||||
|
||||
expect(restrictionNow(), SessionRestriction.unknown);
|
||||
expect(notifier().heldLogin, isNull);
|
||||
});
|
||||
|
||||
test('제한이 새 비밀번호 → 2단계 켜기로 바뀌면 쥔 비밀번호는 그대로다(다음 단계가 쓴다)', () async {
|
||||
await start();
|
||||
await signInRestricted('passwordChange');
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.appRefresh,
|
||||
(_) =>
|
||||
refreshResponse(2, now: h.clock.now, restriction: 'twoFactorSetup'),
|
||||
);
|
||||
|
||||
await container.read(authRepositoryProvider).refresh();
|
||||
await pumpEventsUntil(
|
||||
() => restrictionNow() == SessionRestriction.twoFactorSetup,
|
||||
reason: '갱신 응답의 제한 반영',
|
||||
);
|
||||
|
||||
expect(notifier().heldLogin?.password, 'temp-pw-1');
|
||||
});
|
||||
});
|
||||
|
||||
group('다음 단계로 넘기기 (finishRestrictedStep)', () {
|
||||
test('새 토큰의 세션 칸으로 다음 제한(2단계 켜기)으로 옮긴다', () async {
|
||||
await start();
|
||||
await signInRestricted('passwordChange');
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.appRefresh,
|
||||
(_) =>
|
||||
refreshResponse(2, now: h.clock.now, restriction: 'twoFactorSetup'),
|
||||
);
|
||||
|
||||
expect(
|
||||
await notifier().finishRestrictedStep(),
|
||||
RestrictedStepResult.done,
|
||||
);
|
||||
|
||||
expect(restrictionNow(), SessionRestriction.twoFactorSetup);
|
||||
expect(h.store.accessToken, 'access-2');
|
||||
// 2단계 켜기가 지금 비밀번호로 쓴다
|
||||
expect(notifier().heldLogin, isNotNull);
|
||||
expect(h.push.registered, 0);
|
||||
});
|
||||
|
||||
test('제한이 풀리면 메일로 — 쥔 비밀번호를 지우고 알림 기기를 등록한다', () async {
|
||||
await start();
|
||||
await signInRestricted('twoFactorSetup');
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.appRefresh,
|
||||
(_) => refreshResponse(2, now: h.clock.now),
|
||||
);
|
||||
|
||||
expect(
|
||||
await notifier().finishRestrictedStep(),
|
||||
RestrictedStepResult.done,
|
||||
);
|
||||
|
||||
expect(current(), isA<SignedIn>());
|
||||
expect(restrictionNow(), isNull);
|
||||
expect(notifier().heldLogin, isNull);
|
||||
expect(h.push.registered, 1);
|
||||
});
|
||||
|
||||
test('갱신이 장애면 세션 확인(me)의 지금 값으로 정한다', () async {
|
||||
await start();
|
||||
await signInRestricted('twoFactorSetup');
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.appRefresh,
|
||||
(_) => FakeResponse.fail(503, 'MAIL_001'),
|
||||
);
|
||||
h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()));
|
||||
|
||||
expect(
|
||||
await notifier().finishRestrictedStep(),
|
||||
RestrictedStepResult.done,
|
||||
);
|
||||
|
||||
expect(restrictionNow(), isNull);
|
||||
expect(current(), isA<SignedIn>());
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
});
|
||||
|
||||
test('갱신도 세션 확인도 못 하면 갱신의 실패로 던지고 그 단계에 머문다', () async {
|
||||
await start();
|
||||
await signInRestricted('twoFactorSetup');
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.appRefresh,
|
||||
(_) => FakeResponse.fail(503, 'MAIL_001'),
|
||||
);
|
||||
h.server.on('GET', ApiPaths.me, (_) => FakeResponse.offline);
|
||||
|
||||
final Object error = await errorOf(notifier().finishRestrictedStep());
|
||||
|
||||
expect(apiErrorOf(error).code, 'MAIL_001');
|
||||
expect(restrictionNow(), SessionRestriction.twoFactorSetup);
|
||||
// 화면이 위쪽 상자에 보인다 — 알리지 않는다
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
});
|
||||
|
||||
test('갱신이 세션을 끝냈으면(401) stale — 로그인 화면으로 옮긴다', () async {
|
||||
await start();
|
||||
await signInRestricted('twoFactorSetup');
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.appRefresh,
|
||||
(_) => FakeResponse.fail(401, 'AUTH_001'),
|
||||
);
|
||||
|
||||
expect(
|
||||
await notifier().finishRestrictedStep(),
|
||||
RestrictedStepResult.stale,
|
||||
);
|
||||
await pumpEventsUntil(() => current() is SignedOut, reason: '세션 끝');
|
||||
expect(current(), const SignedOut(reason: SignOutReason.expired));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -180,7 +180,14 @@ void main() {
|
||||
h.clock.advance(const Duration(minutes: 10));
|
||||
await notifier().submitCode('123456');
|
||||
|
||||
expect(current(), const SignedOut(reason: SignOutReason.codeTimedOut));
|
||||
// 첫 단계가 주소를 남기고 위쪽 상자에 까닭을 보인다
|
||||
expect(
|
||||
current(),
|
||||
const SignedOut(
|
||||
reason: SignOutReason.codeTimedOut,
|
||||
email: 'ara.kim@arasystem.it.kr',
|
||||
),
|
||||
);
|
||||
expect(h.server.received('POST', ApiPaths.appLogin), hasLength(1));
|
||||
});
|
||||
|
||||
@@ -350,7 +357,8 @@ void main() {
|
||||
await pumpEventsUntil(() => current() is SignedOut, reason: '세션 끝 알림');
|
||||
|
||||
expect(current(), const SignedOut(reason: SignOutReason.expired));
|
||||
expect(h.notifier.shownCodes, <String>['AUTH_002']);
|
||||
// 로그인 화면의 위쪽 상자가 알린다 — 스낵바는 띄우지 않는다
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import 'package:arasystem_app/core/api/api_paths.dart';
|
||||
import 'package:arasystem_app/features/auth/data/authenticator_launcher.dart';
|
||||
import 'package:arasystem_app/features/auth/data/models/two_factor_enrollment.dart';
|
||||
import 'package:arasystem_app/features/auth/data/models/two_factor_status.dart';
|
||||
import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../support/fakes.dart';
|
||||
import '../../support/login_fakes.dart';
|
||||
import '../../support/provider_harness.dart';
|
||||
|
||||
void main() {
|
||||
group('2단계 등록 모델', () {
|
||||
test('응답을 읽고, toString 은 키 · otpauth 주소를 가린다', () {
|
||||
final TwoFactorEnrollment? enrollment =
|
||||
TwoFactorEnrollment.tryParse(<String, Object?>{
|
||||
'enrollmentId': 'enroll-1',
|
||||
'secret': kTestSecret,
|
||||
'otpauthUrl': kTestOtpauthUrl,
|
||||
'expiresAt': '2026-11-05T03:10:00.000Z',
|
||||
});
|
||||
|
||||
expect(enrollment?.enrollmentId, 'enroll-1');
|
||||
expect(enrollment?.secret, kTestSecret);
|
||||
expect(enrollment?.expiresAt, DateTime.utc(2026, 11, 5, 3, 10));
|
||||
expect(enrollment.toString(), isNot(contains(kTestSecret)));
|
||||
expect(enrollment.toString(), isNot(contains('otpauth')));
|
||||
});
|
||||
|
||||
test('칸이 비었거나 otpauth 주소가 아니면 읽지 않는다', () {
|
||||
expect(TwoFactorEnrollment.tryParse(null), isNull);
|
||||
expect(
|
||||
TwoFactorEnrollment.tryParse(<String, Object?>{
|
||||
'enrollmentId': 'enroll-1',
|
||||
'secret': kTestSecret,
|
||||
'otpauthUrl': 'https://evil.example/otp',
|
||||
}),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
TwoFactorEnrollment.tryParse(<String, Object?>{
|
||||
'enrollmentId': '',
|
||||
'secret': kTestSecret,
|
||||
'otpauthUrl': kTestOtpauthUrl,
|
||||
}),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('상태는 isEnabled 만 본다 — 없으면 읽지 않는다', () {
|
||||
expect(
|
||||
TwoFactorStatus.tryParse(<String, Object?>{
|
||||
'isEnabled': true,
|
||||
})?.isEnabled,
|
||||
isTrue,
|
||||
);
|
||||
expect(TwoFactorStatus.tryParse(<String, Object?>{}), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('[인증 앱에 추가]', () {
|
||||
test('otpauth 주소를 브라우저가 아닌 앱으로 연다', () async {
|
||||
final List<(Uri, LaunchMode)> calls = <(Uri, LaunchMode)>[];
|
||||
final AuthenticatorLauncher launcher = AuthenticatorLauncher(
|
||||
launch: (Uri url, LaunchMode mode) async {
|
||||
calls.add((url, mode));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
expect(await launcher.open(kTestOtpauthUrl), isTrue);
|
||||
expect(calls.single.$1.toString(), kTestOtpauthUrl);
|
||||
expect(calls.single.$2, LaunchMode.externalNonBrowserApplication);
|
||||
});
|
||||
|
||||
test('받을 앱이 없어 플러그인이 던지면(문장에 키가 든다) 삼키고 false', () async {
|
||||
final AuthenticatorLauncher launcher = AuthenticatorLauncher(
|
||||
launch: (Uri url, LaunchMode mode) async => throw PlatformException(
|
||||
code: 'ACTIVITY_NOT_FOUND',
|
||||
message: 'No Activity found to handle intent { $url }',
|
||||
),
|
||||
);
|
||||
|
||||
expect(await launcher.open(kTestOtpauthUrl), isFalse);
|
||||
});
|
||||
|
||||
test('otpauth 주소가 아니면 열지 않는다', () async {
|
||||
int calls = 0;
|
||||
final AuthenticatorLauncher launcher = AuthenticatorLauncher(
|
||||
launch: (Uri url, LaunchMode mode) async {
|
||||
calls++;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
expect(await launcher.open('https://evil.example/otp'), isFalse);
|
||||
expect(calls, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('문의 연락처', () {
|
||||
test('한 번 받으면 다시 묻지 않고, 다듬은 한 줄을 둔다', () async {
|
||||
final ProviderHarness h = ProviderHarness();
|
||||
await h.prepare();
|
||||
final ProviderContainer container = h.container();
|
||||
addTearDown(container.dispose);
|
||||
h.server.on(
|
||||
'GET',
|
||||
ApiPaths.supportContact,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'text': ' IT 담당 홍길동\n02-000-0000 ',
|
||||
}),
|
||||
);
|
||||
final SupportContactNotifier notifier = container.read(
|
||||
supportContactProvider.notifier,
|
||||
);
|
||||
|
||||
await Future.wait(<Future<void>>[
|
||||
notifier.ensureLoaded(),
|
||||
notifier.ensureLoaded(),
|
||||
]);
|
||||
await notifier.ensureLoaded();
|
||||
|
||||
expect(container.read(supportContactProvider), 'IT 담당 홍길동 02-000-0000');
|
||||
expect(h.server.received('GET', ApiPaths.supportContact), hasLength(1));
|
||||
// 로그인 없이 부른다 — 토큰을 싣지 않는다
|
||||
expect(
|
||||
bearerTokenOf(h.server.received('GET', ApiPaths.supportContact).single),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('받지 못하면 알리지 않고 null — 다음에 다시 묻는다', () async {
|
||||
final ProviderHarness h = ProviderHarness();
|
||||
await h.prepare();
|
||||
final ProviderContainer container = h.container();
|
||||
addTearDown(container.dispose);
|
||||
h.server.on('GET', ApiPaths.supportContact, (_) => FakeResponse.offline);
|
||||
final SupportContactNotifier notifier = container.read(
|
||||
supportContactProvider.notifier,
|
||||
);
|
||||
|
||||
await notifier.ensureLoaded();
|
||||
expect(container.read(supportContactProvider), isNull);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
|
||||
h.server.on(
|
||||
'GET',
|
||||
ApiPaths.supportContact,
|
||||
(_) => FakeResponse.ok(<String, Object?>{'text': '사내 메신저 @it'}),
|
||||
);
|
||||
await notifier.ensureLoaded();
|
||||
expect(container.read(supportContactProvider), '사내 메신저 @it');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import 'package:arasystem_app/app.dart';
|
||||
import 'package:arasystem_app/core/api/api_paths.dart';
|
||||
import 'package:arasystem_app/core/utils/provider_retry.dart';
|
||||
import 'package:arasystem_app/features/auth/data/authenticator_launcher.dart';
|
||||
import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart';
|
||||
import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'fakes.dart';
|
||||
import 'provider_harness.dart';
|
||||
|
||||
/// 시험 직원의 주소
|
||||
const String kTestEmail = 'ara.kim@arasystem.it.kr';
|
||||
|
||||
/// 시험 2단계 키(32자 base32)와 otpauth 주소
|
||||
const String kTestSecret = 'K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS';
|
||||
const String kTestOtpauthUrl =
|
||||
'otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=$kTestSecret&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30';
|
||||
|
||||
/// 코드 단계로 답하는 로그인 (계약 5절 — `email` · `name` 은 null, 토큰 없음)
|
||||
FakeResponse codeStepResponse() => FakeResponse.ok(<String, Object?>{
|
||||
'status': 'twoFactorRequired',
|
||||
'email': null,
|
||||
'name': null,
|
||||
'restriction': null,
|
||||
'twoFactorDeadline': null,
|
||||
'tokens': null,
|
||||
});
|
||||
|
||||
/// 로그인을 마친 응답 ([n] 번째 토큰)
|
||||
FakeResponse signedInResponse(
|
||||
int n, {
|
||||
required DateTime now,
|
||||
String? restriction,
|
||||
}) => FakeResponse.ok(<String, Object?>{
|
||||
'status': 'signedIn',
|
||||
...sessionJson(restriction: restriction),
|
||||
'tokens': tokensJson(n, now: now),
|
||||
});
|
||||
|
||||
/// 갱신 응답 ([n] 번째 토큰 · 세션 칸)
|
||||
FakeResponse refreshResponse(
|
||||
int n, {
|
||||
required DateTime now,
|
||||
String? restriction,
|
||||
}) => FakeResponse.ok(<String, Object?>{
|
||||
...sessionJson(restriction: restriction),
|
||||
'tokens': tokensJson(n, now: now),
|
||||
});
|
||||
|
||||
/// 2단계 등록 응답 ([id] 번째 등록 — 키는 같다)
|
||||
FakeResponse enrollmentResponse({String id = 'enroll-1'}) =>
|
||||
FakeResponse(201, <String, Object?>{
|
||||
'success': true,
|
||||
'data': <String, Object?>{
|
||||
'enrollmentId': id,
|
||||
'secret': kTestSecret,
|
||||
'otpauthUrl': kTestOtpauthUrl,
|
||||
'expiresAt': '2026-11-05T03:10:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
/// 2단계 상태 응답
|
||||
FakeResponse twoFactorStatusResponse({required bool enabled}) =>
|
||||
FakeResponse.ok(<String, Object?>{
|
||||
'isEnabled': enabled,
|
||||
'enabledAt': null,
|
||||
'isRequired': true,
|
||||
'deadline': null,
|
||||
});
|
||||
|
||||
/// 보이는 글로 찾는다 — 낱말 가운데서 줄을 바꾸지 않는 글(`KeepAllText`)은 원래 글(화면 낭독기 글)로 찾는다
|
||||
Finder findCopy(String text) => find.byWidgetPredicate(
|
||||
(Widget widget) =>
|
||||
widget is Text && (widget.data == text || widget.semanticsLabel == text),
|
||||
);
|
||||
|
||||
/// 칸 이름으로 그 칸의 입력을 찾는다
|
||||
Finder fieldInput(String label) => find.descendant(
|
||||
of: find.byWidgetPredicate(
|
||||
(Widget widget) => widget is LoginTextField && widget.label == label,
|
||||
),
|
||||
matching: find.byType(TextField),
|
||||
);
|
||||
|
||||
/// 칸 이름으로 그 칸의 컨트롤러 글을 읽는다
|
||||
String fieldText(WidgetTester tester, String label) =>
|
||||
tester.widget<TextField>(fieldInput(label)).controller!.text;
|
||||
|
||||
/// [인증 앱에 추가]를 적어 두는 가짜 — [opens] 가 false 면 받을 앱이 없는 것처럼 답한다
|
||||
class FakeAuthenticatorLauncher extends AuthenticatorLauncher {
|
||||
FakeAuthenticatorLauncher() : super(launch: _never);
|
||||
|
||||
bool opens = true;
|
||||
final List<String> opened = <String>[];
|
||||
|
||||
static Future<bool> _never(Uri url, LaunchMode mode) async => false;
|
||||
|
||||
@override
|
||||
Future<bool> open(String otpauthUrl) async {
|
||||
opened.add(otpauthUrl);
|
||||
return opens;
|
||||
}
|
||||
}
|
||||
|
||||
/// 로그인 틀 시험대 — 가짜 서버 · 메모리 저장소 · 손 시계 · 가짜 인증 앱 열기로 앱 전체를 띄운다
|
||||
class LoginHarness {
|
||||
LoginHarness();
|
||||
|
||||
final ProviderHarness base = ProviderHarness();
|
||||
final FakeAuthenticatorLauncher launcher = FakeAuthenticatorLauncher();
|
||||
|
||||
FakeServer get server => base.server;
|
||||
ManualClock get clock => base.clock;
|
||||
RecordingNotifier get notifier => base.notifier;
|
||||
|
||||
/// 클립보드에 쓴 글 (가짜 플랫폼 통로)
|
||||
final List<String> clipboard = <String>[];
|
||||
|
||||
/// 켜면 클립보드 쓰기가 실패한다(플랫폼 예외)
|
||||
bool clipboardFails = false;
|
||||
|
||||
/// 문의 연락처를 답한다 — 부르지 않으면 가짜 서버가 404 로 답해 화면은 지금 글 그대로다
|
||||
void supportContact(String? text) {
|
||||
server.on(
|
||||
'GET',
|
||||
ApiPaths.supportContact,
|
||||
(_) => FakeResponse.ok(<String, Object?>{'text': text}),
|
||||
);
|
||||
}
|
||||
|
||||
/// 앱을 띄운다 — [refreshToken] 이 있으면 앱을 다시 연 것처럼 세션을 되살린다.
|
||||
/// 화면은 휴대폰 폭(390)에 세로로 길게 — 아래쪽 단추까지 스크롤 없이 누른다
|
||||
Future<ProviderContainer> pump(
|
||||
WidgetTester tester, {
|
||||
String? refreshToken,
|
||||
List<ProviderObserver> observers = const <ProviderObserver>[],
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(390 * 3, 1500 * 3);
|
||||
tester.view.devicePixelRatio = 3;
|
||||
addTearDown(tester.view.reset);
|
||||
await base.prepare(refreshToken: refreshToken);
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(MethodCall call) async {
|
||||
if (call.method == 'Clipboard.setData') {
|
||||
if (clipboardFails) {
|
||||
throw PlatformException(code: 'test', message: '클립보드 쓰기 실패(시험)');
|
||||
}
|
||||
final Object? arguments = call.arguments;
|
||||
if (arguments is Map<Object?, Object?>) {
|
||||
clipboard.add(arguments['text']! as String);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
retry: noAutomaticRetry,
|
||||
observers: observers,
|
||||
overrides: <Override>[
|
||||
...base.overrides,
|
||||
authenticatorLauncherProvider.overrideWithValue(launcher),
|
||||
],
|
||||
child: const App(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
return ProviderScope.containerOf(tester.element(find.byType(App)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user