Skip to content

Commit c842e47

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 2381e9e commit c842e47

13 files changed

+205
-7
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: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,29 @@ void main() {
121121
check(completers(1)).length.equals(1);
122122
});
123123

124+
test('GlobalStore.perAccount loading fails with HTTP status code 401', () => awaitFakeAsync((async) async {
125+
final globalStore = LoadingTestGlobalStore(accounts: [eg.selfAccount]);
126+
final future = globalStore.perAccount(eg.selfAccount.id);
127+
128+
globalStore.completers[eg.selfAccount.id]!
129+
.single.completeError(eg.apiExceptionUnauthorized());
130+
await check(future).throws<AccountNotFoundException>();
131+
}));
132+
133+
test('GlobalStore.perAccount account is logged out while loading; then fails with HTTP status code 401', () => awaitFakeAsync((async) async {
134+
final globalStore = LoadingTestGlobalStore(accounts: [eg.selfAccount]);
135+
final future = globalStore.perAccount(eg.selfAccount.id);
136+
137+
await logOutAccount(globalStore, eg.selfAccount.id);
138+
check(globalStore.takeDoRemoveAccountCalls())
139+
.single.equals(eg.selfAccount.id);
140+
141+
globalStore.completers[eg.selfAccount.id]!
142+
.single.completeError(eg.apiExceptionUnauthorized());
143+
await check(future).throws<AccountNotFoundException>();
144+
check(globalStore.takeDoRemoveAccountCalls()).isEmpty();
145+
}));
146+
124147
// TODO test insertAccount
125148

126149
group('GlobalStore.updateAccount', () {
@@ -990,7 +1013,7 @@ void main() {
9901013
}
9911014

9921015
void checkReloadFailure({
993-
required Future<void> Function(FakeAsync async) completeLoading,
1016+
required FutureOr<void> Function(FakeAsync async) completeLoading,
9941017
}) {
9951018
awaitFakeAsync((async) async {
9961019
await preparePoll();
@@ -1024,6 +1047,14 @@ void main() {
10241047
test('user logged out before new store is loaded', () => awaitFakeAsync((async) async {
10251048
checkReloadFailure(completeLoading: logOutAndCompleteWithNewStore);
10261049
}));
1050+
1051+
void completeWithApiExceptionUnauthorized(FakeAsync async) {
1052+
completers().single.completeError(eg.apiExceptionUnauthorized());
1053+
}
1054+
1055+
test('new store is not loaded, gets HTTP 401 error instead', () => awaitFakeAsync((async) async {
1056+
checkReloadFailure(completeLoading: completeWithApiExceptionUnauthorized);
1057+
}));
10271058
});
10281059
});
10291060

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: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,18 @@ void main() {
6161
group('_PreventEmptyStack', () {
6262
late List<Route<void>> pushedRoutes;
6363
late List<Route<void>> removedRoutes;
64+
late List<Route<void>> poppedRoutes;
6465

6566
Future<void> prepare(WidgetTester tester) async {
6667
addTearDown(testBinding.reset);
6768

6869
pushedRoutes = [];
6970
removedRoutes = [];
71+
poppedRoutes = [];
7072
final testNavObserver = TestNavigatorObserver();
7173
testNavObserver.onPushed = (route, prevRoute) => pushedRoutes.add(route);
7274
testNavObserver.onRemoved = (route, prevRoute) => removedRoutes.add(route);
75+
testNavObserver.onPopped = (route, prevRoute) => poppedRoutes.add(route);
7376

7477
await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver]));
7578
await tester.pump(); // start to load account
@@ -91,6 +94,83 @@ void main() {
9194
check(removedRoutes).single.isA<WidgetRoute>().page.isA<HomePage>();
9295
check(pushedRoutes).single.isA<WidgetRoute>().page.isA<ChooseAccountPage>();
9396
});
97+
98+
testWidgets('push route when popping last route on stack', (tester) async {
99+
await testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false));
100+
101+
testBinding.globalStore.loadPerAccountDuration = Duration.zero;
102+
testBinding.globalStore.loadPerAccountException = eg.apiExceptionUnauthorized();
103+
await prepare(tester);
104+
// The navigator stack should contain only a home page route.
105+
106+
await tester.pump(Duration.zero); // got the error
107+
await tester.pump(TestGlobalStore.removeAccountDuration);
108+
// The navigator stack should contain only a dialog route.
109+
// The home page route was removed because of account logout.
110+
check(testBinding.globalStore.takeDoRemoveAccountCalls())
111+
.single.equals(eg.selfAccount.id);
112+
check(removedRoutes).single.isA<WidgetRoute>().page.isA<HomePage>();
113+
check(poppedRoutes).isEmpty();
114+
check(pushedRoutes).single.isA<DialogRoute<void>>();
115+
pushedRoutes.clear();
116+
117+
await tester.tap(find.byWidget(checkErrorDialog(tester,
118+
expectedTitle: 'Could not connect',
119+
expectedMessage:
120+
'Your account at ${eg.selfAccount.realmUrl} could not be authenticated.'
121+
' Please try logging in again or use another account.')));
122+
// The navigator stack should contain only a choose-account page route.
123+
// After the error dialog is dismissed, it becomes empty,
124+
// so a choose-account page route should be pushed.
125+
check(poppedRoutes).single.isA<DialogRoute<void>>();
126+
check(pushedRoutes).single.isA<WidgetRoute>().page.isA<ChooseAccountPage>();
127+
});
128+
129+
testWidgets('do not push route to non-empty navigator stack', (tester) async {
130+
await testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false));
131+
132+
// Set up long enough loading time to later navigate to the choose-account
133+
// page from the loading page via the "Try another account" button.
134+
const loadPerAccountDuration = Duration(seconds: 30);
135+
assert(loadPerAccountDuration > kTryAnotherAccountWaitPeriod);
136+
testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration;
137+
testBinding.globalStore.loadPerAccountException = eg.apiExceptionUnauthorized();
138+
await prepare(tester);
139+
// The navigator stack should contain only a home page route.
140+
141+
await tester.pump(kTryAnotherAccountWaitPeriod);
142+
await tester.tap(find.text('Try another account'));
143+
await tester.pump(); // tap the button
144+
// The navigator stack should contain the home page route
145+
// and a choose-account page route.
146+
check(removedRoutes).isEmpty();
147+
check(poppedRoutes).isEmpty();
148+
check(pushedRoutes).single.isA<WidgetRoute>().page.isA<ChooseAccountPage>();
149+
pushedRoutes.clear();
150+
151+
await tester.pump(loadPerAccountDuration); // got the error
152+
await tester.pump(TestGlobalStore.removeAccountDuration);
153+
// The navigator stack should contain the choose-account page route
154+
// and a dialog route.
155+
// The home page route was removed because of account logout.
156+
check(testBinding.globalStore.takeDoRemoveAccountCalls())
157+
.single.equals(eg.selfAccount.id);
158+
check(removedRoutes).single.isA<WidgetRoute>().page.isA<HomePage>();
159+
check(poppedRoutes).isEmpty();
160+
check(pushedRoutes).single.isA<DialogRoute<void>>();
161+
pushedRoutes.clear();
162+
163+
await tester.tap(find.byWidget(checkErrorDialog(tester,
164+
expectedTitle: 'Could not connect',
165+
expectedMessage:
166+
'Your account at ${eg.selfAccount.realmUrl} could not be authenticated.'
167+
' Please try logging in again or use another account.')));
168+
// The navigator stack should contain only the choose-account page route.
169+
// No routes should be pushed after dismissing the error dialog,
170+
// because the navigator stack was non-empty.
171+
check(poppedRoutes).single.isA<DialogRoute<void>>();
172+
check(pushedRoutes).isEmpty();
173+
});
94174
});
95175

96176
group('ChooseAccountPage', () {

0 commit comments

Comments
 (0)