Skip to content

Commit a62252d

Browse files
committed
store: Handle invalid API key on register-queue
The method loadPerAccount has two call sites, i.e. places where we send register-queue requests: 1. _reloadPerAccount through [UpdateMachine._handlePollError] (e.g.: expired event queue) 2. perAccount through [PerAccountStoreWidget] (e.g.: loading for the first time) Both sites already expect [AccountNotFoundException] by assuming that the `loadPerAccount` fail is irrecoverable and is handled elsewhere. This partly addresses zulip#890 by handling authentication errors for register-queue. Fixes: zulip#737 Signed-off-by: Zixuan James Li <[email protected]>
1 parent 935b11c commit a62252d

13 files changed

+209
-8
lines changed

assets/l10n/app_en.arb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,13 @@
523523
"@topicValidationErrorMandatoryButEmpty": {
524524
"description": "Topic validation error when topic is required but was empty."
525525
},
526+
"errorInvalidApiKeyMessage": "Your account at {url} could not be authenticated. Please try logging in again or use another account.",
527+
"@errorInvalidApiKeyMessage": {
528+
"description": "Error message in the dialog for invalid API key.",
529+
"placeholders": {
530+
"url": {"type": "String", "example": "http://chat.example.com/"}
531+
}
532+
},
526533
"errorInvalidResponse": "The server sent an invalid response",
527534
"@errorInvalidResponse": {
528535
"description": "Error message when an API call returned an invalid response."

lib/generated/l10n/zulip_localizations.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,12 @@ abstract class ZulipLocalizations {
801801
/// **'Topics are required in this organization.'**
802802
String get topicValidationErrorMandatoryButEmpty;
803803

804+
/// Error message in the dialog for invalid API key.
805+
///
806+
/// In en, this message translates to:
807+
/// **'Your account at {url} could not be authenticated. Please try logging in again or use another account.'**
808+
String errorInvalidApiKeyMessage(String url);
809+
804810
/// Error message when an API call returned an invalid response.
805811
///
806812
/// In en, this message translates to:

lib/generated/l10n/zulip_localizations_ar.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,11 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
404404
@override
405405
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';
406406

407+
@override
408+
String errorInvalidApiKeyMessage(String url) {
409+
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';
410+
}
411+
407412
@override
408413
String get errorInvalidResponse => 'The server sent an invalid response';
409414

lib/generated/l10n/zulip_localizations_en.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,11 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
404404
@override
405405
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';
406406

407+
@override
408+
String errorInvalidApiKeyMessage(String url) {
409+
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';
410+
}
411+
407412
@override
408413
String get errorInvalidResponse => 'The server sent an invalid response';
409414

lib/generated/l10n/zulip_localizations_ja.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,11 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
404404
@override
405405
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';
406406

407+
@override
408+
String errorInvalidApiKeyMessage(String url) {
409+
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';
410+
}
411+
407412
@override
408413
String get errorInvalidResponse => 'The server sent an invalid response';
409414

lib/generated/l10n/zulip_localizations_nb.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,11 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
404404
@override
405405
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';
406406

407+
@override
408+
String errorInvalidApiKeyMessage(String url) {
409+
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';
410+
}
411+
407412
@override
408413
String get errorInvalidResponse => 'The server sent an invalid response';
409414

lib/generated/l10n/zulip_localizations_pl.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,11 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
404404
@override
405405
String get topicValidationErrorMandatoryButEmpty => 'Wątki są wymagane przez tę organizację.';
406406

407+
@override
408+
String errorInvalidApiKeyMessage(String url) {
409+
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';
410+
}
411+
407412
@override
408413
String get errorInvalidResponse => 'Nieprawidłowa odpowiedź serwera';
409414

lib/generated/l10n/zulip_localizations_ru.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,11 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
404404
@override
405405
String get topicValidationErrorMandatoryButEmpty => 'Темы обязательны в этой организации.';
406406

407+
@override
408+
String errorInvalidApiKeyMessage(String url) {
409+
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';
410+
}
411+
407412
@override
408413
String get errorInvalidResponse => 'Получен недопустимый ответ сервера';
409414

lib/generated/l10n/zulip_localizations_sk.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,11 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
404404
@override
405405
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';
406406

407+
@override
408+
String errorInvalidApiKeyMessage(String url) {
409+
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';
410+
}
411+
407412
@override
408413
String get errorInvalidResponse => 'Server poslal nesprávnu odpoveď';
409414

lib/model/store.dart

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import '../api/backoff.dart';
1919
import '../api/route/realm.dart';
2020
import '../log.dart';
2121
import '../notifications/receive.dart';
22+
import 'actions.dart';
2223
import 'autocomplete.dart';
2324
import 'database.dart';
2425
import 'emoji.dart';
@@ -149,7 +150,34 @@ abstract class GlobalStore extends ChangeNotifier {
149150
/// and/or [perAccountSync].
150151
Future<PerAccountStore> loadPerAccount(int accountId) async {
151152
assert(_accounts.containsKey(accountId));
152-
final store = await doLoadPerAccount(accountId);
153+
final PerAccountStore store;
154+
try {
155+
store = await doLoadPerAccount(accountId);
156+
} catch (e) {
157+
switch (e) {
158+
case HttpException(httpStatus: 401):
159+
// The API key is invalid and the store can never be loaded
160+
// unless the user retries manually.
161+
final account = getAccount(accountId);
162+
if (account == null) {
163+
// The account was logged out during `await doLoadPerAccount`.
164+
// Here, that seems possible only by the user's own action;
165+
// the logout can't have been done programmatically.
166+
// Even if it were, it would have come with its own UI feedback.
167+
// Anyway, skip showing feedback, to not be confusing or repetitive.
168+
throw AccountNotFoundException();
169+
}
170+
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
171+
reportErrorToUserModally(
172+
zulipLocalizations.errorCouldNotConnectTitle,
173+
message: zulipLocalizations.errorInvalidApiKeyMessage(
174+
account.realmUrl.toString()));
175+
await logOutAccount(this, accountId);
176+
throw AccountNotFoundException();
177+
default:
178+
rethrow;
179+
}
180+
}
153181
if (!_accounts.containsKey(accountId)) {
154182
// TODO(#1354): handle this earlier
155183
// [removeAccount] was called during [doLoadPerAccount].
@@ -914,12 +942,19 @@ class UpdateMachine {
914942
try {
915943
return await registerQueue(connection);
916944
} catch (e, s) {
917-
assert(debugLog('Error fetching initial snapshot: $e'));
918-
// Print stack trace in its own log entry; log entries are truncated
919-
// at 1 kiB (at least on Android), and stack can be longer than that.
920-
assert(debugLog('Stack:\n$s'));
921-
assert(debugLog('Backing off, then will retry…'));
922945
// TODO(#890): tell user if initial-fetch errors persist, or look non-transient
946+
switch (e) {
947+
case HttpException(httpStatus: 401):
948+
// We cannot recover from this error through retrying.
949+
// Leave it to [GlobalStore.loadPerAccount].
950+
rethrow;
951+
default:
952+
assert(debugLog('Error fetching initial snapshot: $e'));
953+
// Print stack trace in its own log entry; log entries are truncated
954+
// at 1 kiB (at least on Android), and stack can be longer than that.
955+
assert(debugLog('Stack:\n$s'));
956+
}
957+
assert(debugLog('Backing off, then will retry…'));
923958
await (backoffMachine ??= BackoffMachine()).wait();
924959
assert(debugLog('… Backoff wait complete, retrying initial fetch.'));
925960
}

test/model/store_test.dart

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ import 'package:flutter/foundation.dart';
77
import 'package:http/http.dart' as http;
88
import 'package:test/scaffolding.dart';
99
import 'package:zulip/api/core.dart';
10+
import 'package:zulip/api/exception.dart';
1011
import 'package:zulip/api/model/events.dart';
1112
import 'package:zulip/api/model/model.dart';
1213
import 'package:zulip/api/route/events.dart';
1314
import 'package:zulip/api/route/messages.dart';
1415
import 'package:zulip/api/route/realm.dart';
15-
import 'package:zulip/log.dart';
1616
import 'package:zulip/model/actions.dart';
17+
import 'package:zulip/log.dart';
1718
import 'package:zulip/model/store.dart';
1819
import 'package:zulip/notifications/receive.dart';
1920

@@ -121,6 +122,39 @@ void main() {
121122
check(completers(1)).length.equals(1);
122123
});
123124

125+
test('GlobalStore.perAccount loading fails with HTTP status code 401', () => awaitFakeAsync((async) async {
126+
addTearDown(testBinding.reset);
127+
128+
await testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false));
129+
testBinding.globalStore.loadPerAccountException = ZulipApiException(
130+
routeName: '/register', code: 'UNAUTHORIZED', httpStatus: 401,
131+
data: {}, message: '');
132+
await check(testBinding.globalStore.perAccount(eg.selfAccount.id))
133+
.throws<AccountNotFoundException>();
134+
135+
check(testBinding.globalStore.takeDoRemoveAccountCalls())
136+
.single.equals(eg.selfAccount.id);
137+
}));
138+
139+
test('GlobalStore.perAccount account is logged out while loading; then fails with HTTP status code 401', () => awaitFakeAsync((async) async {
140+
addTearDown(testBinding.reset);
141+
142+
await testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false));
143+
144+
testBinding.globalStore.loadPerAccountDuration = Duration(seconds: 2);
145+
testBinding.globalStore.loadPerAccountException = ZulipApiException(
146+
routeName: '/register', code: 'UNAUTHORIZED', httpStatus: 401,
147+
data: {}, message: '');
148+
final future = testBinding.globalStore.perAccount(eg.selfAccount.id);
149+
check(testBinding.globalStore.takeDoRemoveAccountCalls()).isEmpty();
150+
151+
await logOutAccount(testBinding.globalStore, eg.selfAccount.id);
152+
check(testBinding.globalStore.takeDoRemoveAccountCalls()).single;
153+
154+
await check(future).throws<AccountNotFoundException>();
155+
check(testBinding.globalStore.takeDoRemoveAccountCalls()).isEmpty();
156+
}));
157+
124158
// TODO test insertAccount
125159

126160
group('GlobalStore.updateAccount', () {
@@ -975,7 +1009,7 @@ void main() {
9751009
group('errors while reloading', () {
9761010
void checkReloadFailure({
9771011
required void Function() prepareError,
978-
required Future<void> Function(FakeAsync async) callbackWhileReloading,
1012+
required FutureOr<void> Function(FakeAsync async) callbackWhileReloading,
9791013
}) {
9801014
awaitFakeAsync((async) async {
9811015
void complete() => (globalStore as LoadingTestGlobalStore)
@@ -1017,11 +1051,23 @@ void main() {
10171051
await future;
10181052
}
10191053

1054+
void prepareLoadPerAccountZulipApiExceptionUnauthorized(FakeAsync async) {
1055+
globalStore.loadPerAccountException = ZulipApiException(
1056+
routeName: '/register', code: 'UNAUTHORIZED', httpStatus: 401,
1057+
data: {}, message: '');
1058+
}
1059+
10201060
test('user logged out', () => awaitFakeAsync((async) async {
10211061
checkReloadFailure(
10221062
prepareError: prepareExpiredEventQueue,
10231063
callbackWhileReloading: doLogOutAccount);
10241064
}));
1065+
1066+
test('got status 401 ZulipApiException', () => awaitFakeAsync((async) async {
1067+
checkReloadFailure(
1068+
prepareError: prepareUnexpectedLoopError,
1069+
callbackWhileReloading: prepareLoadPerAccountZulipApiExceptionUnauthorized);
1070+
}));
10251071
});
10261072
});
10271073

test/model/test_store.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ class TestGlobalStore extends GlobalStore {
129129

130130
static const Duration removeAccountDuration = Duration(milliseconds: 1);
131131
Duration? loadPerAccountDuration;
132+
Object? loadPerAccountException;
132133

133134
/// Consume the log of calls made to [doRemoveAccount].
134135
List<int> takeDoRemoveAccountCalls() {
@@ -150,6 +151,9 @@ class TestGlobalStore extends GlobalStore {
150151
if (loadPerAccountDuration != null) {
151152
await Future<void>.delayed(loadPerAccountDuration!);
152153
}
154+
if (loadPerAccountException != null) {
155+
throw loadPerAccountException!;
156+
}
153157
final initialSnapshot = _initialSnapshots[accountId]!;
154158
final store = PerAccountStore.fromInitialSnapshot(
155159
globalStore: this,

test/widgets/app_test.dart

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:async';
33
import 'package:checks/checks.dart';
44
import 'package:flutter/material.dart';
55
import 'package:flutter_test/flutter_test.dart';
6+
import 'package:zulip/api/exception.dart';
67
import 'package:zulip/log.dart';
78
import 'package:zulip/model/actions.dart';
89
import 'package:zulip/model/database.dart';
@@ -61,22 +62,89 @@ void main() {
6162
group('_PreventEmptyStack', () {
6263
late List<Route<void>> pushedRoutes;
6364
late List<Route<void>> removedRoutes;
65+
late List<Route<void>> poppedRoutes;
6466

6567
Future<void> prepare(WidgetTester tester) async {
6668
addTearDown(testBinding.reset);
6769

6870
pushedRoutes = [];
6971
removedRoutes = [];
72+
poppedRoutes = [];
7073
final testNavObserver = TestNavigatorObserver();
7174
testNavObserver.onPushed = (route, prevRoute) => pushedRoutes.add(route);
7275
testNavObserver.onRemoved = (route, prevRoute) => removedRoutes.add(route);
76+
testNavObserver.onPopped = (route, prevRoute) => poppedRoutes.add(route);
7377

7478
await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver]));
7579
await tester.pump(); // start to load account
7680
check(pushedRoutes).single.isA<WidgetRoute>().page.isA<HomePage>();
7781
pushedRoutes.clear();
7882
}
7983

84+
testWidgets('do not push route to non-empty navigator stack', (tester) async {
85+
const loadPerAccountDuration = Duration(seconds: 30);
86+
assert(loadPerAccountDuration > kTryAnotherAccountWaitPeriod);
87+
testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration;
88+
testBinding.globalStore.loadPerAccountException = ZulipApiException(
89+
routeName: '/register', code: 'UNAUTHORIZED', httpStatus: 401,
90+
data: {}, message: '');
91+
await testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false));
92+
await prepare(tester);
93+
94+
await tester.pump(kTryAnotherAccountWaitPeriod);
95+
await tester.tap(find.text('Try another account'));
96+
await tester.pump(); // tap the button
97+
check(pushedRoutes).single.isA<WidgetRoute>().page.isA<ChooseAccountPage>();
98+
pushedRoutes.clear();
99+
100+
await tester.pump(loadPerAccountDuration); // got the error
101+
await tester.pump(TestGlobalStore.removeAccountDuration);
102+
check(testBinding.globalStore.takeDoRemoveAccountCalls())
103+
.single.equals(eg.selfAccount.id);
104+
check(removedRoutes).single.isA<WidgetRoute>().page.isA<HomePage>();
105+
check(poppedRoutes).isEmpty();
106+
check(pushedRoutes).single.isA<DialogRoute<void>>();
107+
pushedRoutes.clear();
108+
109+
await tester.tap(find.byWidget(checkErrorDialog(tester,
110+
expectedTitle: 'Could not connect',
111+
expectedMessage:
112+
'Your account at https://chat.example/ could not be authenticated.'
113+
' Please try logging in again or use another account.')));
114+
// No more routes are pushed after dismissing the error dialog,
115+
// because the navigator stack was non-empty.
116+
check(poppedRoutes).single.isA<DialogRoute<void>>();
117+
check(pushedRoutes).isEmpty();
118+
});
119+
120+
testWidgets('push route when popping last route on stack', (tester) async {
121+
testBinding.globalStore.loadPerAccountDuration = Duration.zero;
122+
testBinding.globalStore.loadPerAccountException = ZulipApiException(
123+
routeName: '/register', code: 'UNAUTHORIZED', httpStatus: 401,
124+
data: {}, message: '');
125+
await testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false));
126+
await prepare(tester);
127+
128+
await tester.pump(Duration.zero); // got the error
129+
await tester.pump(TestGlobalStore.removeAccountDuration);
130+
check(testBinding.globalStore.takeDoRemoveAccountCalls())
131+
.single.equals(eg.selfAccount.id);
132+
check(removedRoutes).single.isA<WidgetRoute>().page.isA<HomePage>();
133+
check(poppedRoutes).isEmpty();
134+
check(pushedRoutes).single.isA<DialogRoute<void>>();
135+
pushedRoutes.clear();
136+
137+
await tester.tap(find.byWidget(checkErrorDialog(tester,
138+
expectedTitle: 'Could not connect',
139+
expectedMessage:
140+
'Your account at https://chat.example/ could not be authenticated.'
141+
' Please try logging in again or use another account.')));
142+
// The navigator stack became empty after dismissing the error dialog,
143+
// so a choose-account page route was pushed.
144+
check(poppedRoutes).single.isA<DialogRoute<void>>();
145+
check(pushedRoutes).single.isA<WidgetRoute>().page.isA<ChooseAccountPage>();
146+
});
147+
80148
testWidgets('push route when removing last route on stack', (tester) async {
81149
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
82150
await prepare(tester);

0 commit comments

Comments
 (0)