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:
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user