fix(flutter): 모양이 틀린 로그인 응답의 토큰 · 다시 확인 재사용 창 · 기다리던 다시 맞추기를 고친다

앱 기반 3차 확인에서 나온 낮음 넷이다.

- 토큰은 실렸는데 모양이 틀린 로그인 응답은 응답을 뺀 실패로 던지고, 읽을 수 있는 토큰으로
  조용히 logout 한다(비밀 요청의 실패 예외는 응답 본문도 비운다)
- 확인하지 못한(unknown) 다시 확인 결과는 처음 물은 때부터 5초만 다시 쓴다
- 로그아웃 · 다른 계정 · 세션 끝 때 기다리던 다시 맞추기에 stale 로 답한다 — 새 로그인의 오류가
  앞 세션의 me 를 기다리지 않게
- postAppLogout 주석을 사실대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 13:05:46 +09:00
parent 2bb7fa4cdb
commit dd02b9621a
7 changed files with 189 additions and 21 deletions
@@ -971,4 +971,31 @@ void main() {
expect(api.server.received('GET', _mailState), hasLength(1));
});
});
group('3차 — 다시 쓰는 결과의 창', () {
test(
'확인하지 못한(unknown) 결과는 새로 물은 때부터 5초만 다시 쓴다(다시 쓴 결과로 창이 늘지 않는다)',
() async {
await api.signIn();
api.recheckAnswer = RestrictionCheck.unknown;
api.server.on(
'GET',
_mailState,
(_) => FakeResponse.fail(403, 'AUTH_010'),
);
await errorOf(api.dio.get<Object?>(_mailState));
expect(api.recheckCount, 1);
api.clock.advance(const Duration(seconds: 3));
await errorOf(api.dio.get<Object?>(_mailState));
expect(api.recheckCount, 1);
// 처음 물은 때부터 6초 — 3초에 다시 쓴 것으로 창이 늘었다면 여기서도 묻지 않았을 것이다
api.clock.advance(const Duration(seconds: 3));
await errorOf(api.dio.get<Object?>(_mailState));
expect(api.recheckCount, 2);
},
);
});
}
@@ -660,4 +660,90 @@ void main() {
expect(h.store.hasSession, isFalse);
});
});
group('3차 — 남은 비밀 · 기다림', () {
test(
'signedIn 에 토큰은 실렸는데 모양이 틀리면 응답을 뺀 실패로 던지고 그 서버 세션을 조용히 끝낸다',
() async {
await start();
await settled();
h.server.on(
'POST',
ApiPaths.appLogin,
(_) => FakeResponse.ok(<String, Object?>{
'status': 'signedIn',
'email': 'ara.kim@arasystem.it.kr',
'tokens': tokensJson(1),
}),
);
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
final Object error = await errorOf(
notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw'),
);
final DioException failure = error as DioException;
expect(apiErrorOf(failure).code, 'SYS_001');
expect(failure.response, isNull);
expect(failure.requestOptions.data, isNull);
expect(h.store.hasSession, isFalse);
await pumpEventsUntil(() => logouts().isNotEmpty, reason: '조용한 로그아웃');
expect(logouts().single.data, <String, Object?>{
'refreshToken': 'refresh-1',
});
expect(bearerTokenOf(logouts().single), 'access-1');
},
);
test(
'다시 맞추기를 기다리는 동안 로그아웃하면 stale 로 답해 막힌 요청을 놓아주고, 새 로그인 오류가 그 뒤에서 기다리지 않는다',
() async {
await start(refreshToken: 'refresh-1');
refreshGives(2);
final Completer<void> slowMe = Completer<void>();
int meCalls = 0;
h.server.on('GET', ApiPaths.me, (_) async {
meCalls++;
if (meCalls > 1) await slowMe.future;
return FakeResponse.ok(sessionJson());
});
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
h.server.on(
'GET',
'/mailboxes',
(_) => FakeResponse.fail(403, 'AUTH_010'),
);
h.server.on(
'POST',
ApiPaths.appLogin,
(_) => FakeResponse.fail(401, 'AUTH_004'),
);
expect(await settled(), isA<SignedIn>());
final Future<Object> blocked = errorOf(
container.read(dioProvider).get<Object?>('/mailboxes'),
);
await pumpEventsUntil(
() => meCalls == 2,
reason: '다시 맞추기의 me 가 서버에 닿기',
);
await notifier().switchAccount();
// 앞 세션의 me 는 아직 끝나지 않았지만 막힌 요청은 조용히 끝난다
expect(apiErrorOf(await blocked).code, 'AUTH_010');
expect(h.notifier.shownCodes, isEmpty);
// 다른 계정의 새 로그인 오류도 기다리지 않고 온다
final Object loginError = await errorOf(
notifier().signIn(email: 'other@arasystem.it.kr', password: 'wrong'),
);
expect(apiErrorOf(loginError).code, 'AUTH_004');
expect(slowMe.isCompleted, isFalse);
slowMe.complete();
await pumpEventsUntil(() => true);
expect(current(), const SignedOut(reason: SignOutReason.switchAccount));
},
);
});
}