Skip to content

Commit

Permalink
store: Handle invalid API key on register-queue
Browse files Browse the repository at this point in the history
The method loadPerAccount has two call sites, i.e. places
where we send register-queue requests:

1. _reloadPerAccount through [UpdateMachine._handlePollError]
2. perAccount through [PerAccountStoreWidget] (the common case)

We utilize the existing [AccountNotFoundException] because
invalidating invalid auth keys effectively logs out the account, that it
can no longer be found in the store.  [PerAccountStoreWidget] already
expects this error by ignoring it and waiting for the route to be
removed:

```dart
  try {
    // If this succeeds, globalStore will notify listeners, and
    // [didChangeDependencies] will run again, this time in the
    // `store != null` case above.
    await globalStore.perAccount(widget.accountId);
  } on AccountNotFoundException {
    // The account was logged out while its store was loading.
    // This widget will be showing [placeholder] perpetually,
    // but that's OK as long as other code will be removing it from the UI
    // (usually by using [routeToRemoveOnLogout]).
  }
```
(included because unchanged code is not in the diff)

To handle 1, we apply the same expectation that the account gets logged
out when the exception happens.  For `_handlePollError`, while the store
was not replaced at all, the end result of having the old store disposed
is still expected.

This partly addresses #890 by handling authentication errors for
register-queue.

Fixes: #737

Signed-off-by: Zixuan James Li <[email protected]>
  • Loading branch information
PIG208 committed Feb 8, 2025
1 parent d4d4d49 commit 2859759
Show file tree
Hide file tree
Showing 13 changed files with 214 additions and 8 deletions.
7 changes: 7 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,13 @@
"@topicValidationErrorMandatoryButEmpty": {
"description": "Topic validation error when topic is required but was empty."
},
"errorInvalidApiKeyMessage": "Your account at {url} could not be authenticated. Please try logging in again or use another account.",
"@errorInvalidApiKeyMessage": {
"description": "Error message in the dialog for invalid API key.",
"placeholders": {
"url": {"type": "String", "example": "http://chat.example.com/"}
}
},
"errorInvalidResponse": "The server sent an invalid response",
"@errorInvalidResponse": {
"description": "Error message when an API call returned an invalid response."
Expand Down
6 changes: 6 additions & 0 deletions lib/generated/l10n/zulip_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,12 @@ abstract class ZulipLocalizations {
/// **'Topics are required in this organization.'**
String get topicValidationErrorMandatoryButEmpty;

/// Error message in the dialog for invalid API key.
///
/// In en, this message translates to:
/// **'Your account at {url} could not be authenticated. Please try logging in again or use another account.'**
String errorInvalidApiKeyMessage(String url);

/// Error message when an API call returned an invalid response.
///
/// In en, this message translates to:
Expand Down
5 changes: 5 additions & 0 deletions lib/generated/l10n/zulip_localizations_ar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,11 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
@override
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';

@override
String errorInvalidApiKeyMessage(String url) {
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';
}

@override
String get errorInvalidResponse => 'The server sent an invalid response';

Expand Down
5 changes: 5 additions & 0 deletions lib/generated/l10n/zulip_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,11 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
@override
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';

@override
String errorInvalidApiKeyMessage(String url) {
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';
}

@override
String get errorInvalidResponse => 'The server sent an invalid response';

Expand Down
5 changes: 5 additions & 0 deletions lib/generated/l10n/zulip_localizations_ja.dart
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,11 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
@override
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';

@override
String errorInvalidApiKeyMessage(String url) {
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';
}

@override
String get errorInvalidResponse => 'The server sent an invalid response';

Expand Down
5 changes: 5 additions & 0 deletions lib/generated/l10n/zulip_localizations_nb.dart
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,11 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
@override
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';

@override
String errorInvalidApiKeyMessage(String url) {
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';
}

@override
String get errorInvalidResponse => 'The server sent an invalid response';

Expand Down
5 changes: 5 additions & 0 deletions lib/generated/l10n/zulip_localizations_pl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,11 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
@override
String get topicValidationErrorMandatoryButEmpty => 'Wątki są wymagane przez tę organizację.';

@override
String errorInvalidApiKeyMessage(String url) {
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';
}

@override
String get errorInvalidResponse => 'Nieprawidłowa odpowiedź serwera';

Expand Down
5 changes: 5 additions & 0 deletions lib/generated/l10n/zulip_localizations_ru.dart
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,11 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
@override
String get topicValidationErrorMandatoryButEmpty => 'Темы обязательны в этой организации.';

@override
String errorInvalidApiKeyMessage(String url) {
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';
}

@override
String get errorInvalidResponse => 'Получен недопустимый ответ сервера';

Expand Down
5 changes: 5 additions & 0 deletions lib/generated/l10n/zulip_localizations_sk.dart
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,11 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
@override
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';

@override
String errorInvalidApiKeyMessage(String url) {
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';
}

@override
String get errorInvalidResponse => 'Server poslal nesprávnu odpoveď';

Expand Down
55 changes: 47 additions & 8 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import '../api/backoff.dart';
import '../api/route/realm.dart';
import '../log.dart';
import '../notifications/receive.dart';
import 'actions.dart';
import 'autocomplete.dart';
import 'database.dart';
import 'emoji.dart';
Expand Down Expand Up @@ -149,7 +150,29 @@ abstract class GlobalStore extends ChangeNotifier {
/// and/or [perAccountSync].
Future<PerAccountStore> loadPerAccount(int accountId) async {
assert(_accounts.containsKey(accountId));
final store = await doLoadPerAccount(accountId);
final realmUrl = getAccount(accountId)!.realmUrl;
final PerAccountStore store;
try {
store = await doLoadPerAccount(accountId);
} catch (e) {
switch (e) {
case HttpException(httpStatus: 401):
// The API key is invalid and the store can never be loaded
// unless the user retries manually.
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
reportErrorToUserModally(
zulipLocalizations.errorCouldNotConnectTitle,
details: zulipLocalizations.errorInvalidApiKeyMessage(
realmUrl.toString()));
// The account could be logged out from the choose-account page.
if (_accounts.containsKey(accountId)) {
await logOutAccount(this, accountId);
}
throw AccountNotFoundException();
default:
rethrow;
}
}
if (!_accounts.containsKey(accountId)) {
// [removeAccount] was called during [doLoadPerAccount].
store.dispose();
Expand Down Expand Up @@ -912,12 +935,19 @@ class UpdateMachine {
try {
return await registerQueue(connection);
} catch (e, s) {
assert(debugLog('Error fetching initial snapshot: $e'));
// Print stack trace in its own log entry; log entries are truncated
// at 1 kiB (at least on Android), and stack can be longer than that.
assert(debugLog('Stack:\n$s'));
assert(debugLog('Backing off, then will retry…'));
// TODO(#890): tell user if initial-fetch errors persist, or look non-transient
switch (e) {
case HttpException(httpStatus: 401):
// We cannot recover from this error through retrying.
// Leave it to [GlobalStore.loadPerAccount].
rethrow;
default:
assert(debugLog('Error fetching initial snapshot: $e'));
// Print stack trace in its own log entry; log entries are truncated
// at 1 kiB (at least on Android), and stack can be longer than that.
assert(debugLog('Stack:\n$s'));
}
assert(debugLog('Backing off, then will retry…'));
await (backoffMachine ??= BackoffMachine()).wait();
assert(debugLog('… Backoff wait complete, retrying initial fetch.'));
}
Expand Down Expand Up @@ -1218,8 +1248,17 @@ class UpdateMachine {
if (_disposed) return;
}

await store._globalStore._reloadPerAccount(store.accountId);
assert(_disposed);
try {
await store._globalStore._reloadPerAccount(store.accountId);
} on AccountNotFoundException {
// The account was logged out while we retry to replace the store,
// but that's OK as long as other code will be removing it from the UI
// (usually by using [routeToRemoveOnLogout]).
assert(debugLog('… Event queue not replaced; account logged out.'));
return;
} finally {
assert(_disposed);
}
assert(debugLog('… Event queue replaced.'));
}

Expand Down
54 changes: 54 additions & 0 deletions test/model/store_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:test/scaffolding.dart';
import 'package:zulip/api/core.dart';
import 'package:zulip/api/exception.dart';
import 'package:zulip/api/model/events.dart';
import 'package:zulip/api/model/model.dart';
import 'package:zulip/api/route/events.dart';
import 'package:zulip/api/route/messages.dart';
import 'package:zulip/api/route/realm.dart';
import 'package:zulip/model/actions.dart';
import 'package:zulip/log.dart';
import 'package:zulip/model/store.dart';
import 'package:zulip/notifications/receive.dart';
Expand Down Expand Up @@ -120,6 +122,39 @@ void main() {
check(completers(1)).length.equals(1);
});

test('GlobalStore.perAccount loading fails with HTTP status code 401', () => awaitFakeAsync((async) async {
addTearDown(testBinding.reset);

await testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false));
testBinding.globalStore.loadPerAccountException = ZulipApiException(
routeName: '/register', code: 'UNAUTHORIZED', httpStatus: 401,
data: {}, message: '');
await check(testBinding.globalStore.perAccount(eg.selfAccount.id))
.throws<AccountNotFoundException>();

check(testBinding.globalStore.takeDoRemoveAccountCalls())
.single.equals(eg.selfAccount.id);
}));

test('GlobalStore.perAccount account is logged out while loading; then fails with HTTP status code 401', () => awaitFakeAsync((async) async {
addTearDown(testBinding.reset);

await testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false));

testBinding.globalStore.loadPerAccountDuration = Duration(seconds: 2);
testBinding.globalStore.loadPerAccountException = ZulipApiException(
routeName: '/register', code: 'UNAUTHORIZED', httpStatus: 401,
data: {}, message: '');
final future = testBinding.globalStore.perAccount(eg.selfAccount.id);
check(testBinding.globalStore.takeDoRemoveAccountCalls()).isEmpty();

await logOutAccount(testBinding.globalStore, eg.selfAccount.id);
check(testBinding.globalStore.takeDoRemoveAccountCalls()).single;

await check(future).throws<AccountNotFoundException>();
check(testBinding.globalStore.takeDoRemoveAccountCalls()).isEmpty();
}));

// TODO test insertAccount

group('GlobalStore.updateAccount', () {
Expand Down Expand Up @@ -822,6 +857,25 @@ void main() {
checkReload(prepareHandleEventError);
});

test('unexpected poll error, but reload fails with HTTP status code 401; log out', () => awaitFakeAsync((async) async {
await preparePoll();

prepareUnexpectedLoopError();
updateMachine.debugAdvanceLoop();
async.elapse(Duration.zero);
check(store).isLoading.isTrue();

globalStore.loadPerAccountException = ZulipApiException(
routeName: '/register', code: 'UNAUTHORIZED', httpStatus: 401,
data: {}, message: '');
// The reload doesn't happen immediately; there's a timer.
check(async.pendingTimers).length.equals(1);
async.flushTimers();

check(globalStore.takeDoRemoveAccountCalls().single)
.equals(eg.selfAccount.id);
}));

group('report error', () {
String? lastReportedError;
String? takeLastReportedError() {
Expand Down
4 changes: 4 additions & 0 deletions test/model/test_store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ class TestGlobalStore extends GlobalStore {

static const Duration removeAccountDuration = Duration(milliseconds: 1);
Duration? loadPerAccountDuration;
Object? loadPerAccountException;

/// Consume the log of calls made to [doRemoveAccount].
List<int> takeDoRemoveAccountCalls() {
Expand All @@ -150,6 +151,9 @@ class TestGlobalStore extends GlobalStore {
if (loadPerAccountDuration != null) {
await Future<void>.delayed(loadPerAccountDuration!);
}
if (loadPerAccountException != null) {
throw loadPerAccountException!;
}
final initialSnapshot = _initialSnapshots[accountId]!;
final store = PerAccountStore.fromInitialSnapshot(
globalStore: this,
Expand Down
61 changes: 61 additions & 0 deletions test/widgets/app_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:async';
import 'package:checks/checks.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:zulip/api/exception.dart';
import 'package:zulip/log.dart';
import 'package:zulip/model/actions.dart';
import 'package:zulip/model/database.dart';
Expand Down Expand Up @@ -77,6 +78,66 @@ void main() {
pushedRoutes.clear();
}

testWidgets('do not push route to non-empty navigator stack', (tester) async {
const loadPerAccountDuration = Duration(seconds: 30);
assert(loadPerAccountDuration > kTryAnotherAccountWaitPeriod);
testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration;
testBinding.globalStore.loadPerAccountException = ZulipApiException(
routeName: '/register', code: 'UNAUTHORIZED', httpStatus: 401,
data: {}, message: '');
await testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false));
await prepare(tester);

await tester.pump(kTryAnotherAccountWaitPeriod);
await tester.tap(find.text('Try another account'));
await tester.pump(); // tap the button
check(pushedRoutes).single.isA<WidgetRoute>().page.isA<ChooseAccountPage>();
pushedRoutes.clear();

await tester.pump(loadPerAccountDuration); // got the error
await tester.pump(TestGlobalStore.removeAccountDuration);
check(pushedRoutes).single.isA<DialogRoute<void>>();
pushedRoutes.clear();
check(removedRoutes).single.isA<WidgetRoute>().page.isA<HomePage>();
check(testBinding.globalStore.takeDoRemoveAccountCalls())
.single.equals(eg.selfAccount.id);

await tester.tap(find.byWidget(checkErrorDialog(tester,
expectedTitle: 'Could not connect',
expectedMessage:
'Your account at https://chat.example/ could not be authenticated.'
' Please try logging in again or use another account.')));
// No more routes are pushed after dismissing the error dialog,
// because the navigator stack was non-empty.
check(pushedRoutes).isEmpty();
});

testWidgets('push route when popping last route on stack', (tester) async {
testBinding.globalStore.loadPerAccountDuration = Duration.zero;
testBinding.globalStore.loadPerAccountException = ZulipApiException(
routeName: '/register', code: 'UNAUTHORIZED', httpStatus: 401,
data: {}, message: '');
await testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false));
await prepare(tester);

await tester.pump(Duration.zero); // got the error
await tester.pump(TestGlobalStore.removeAccountDuration);
check(pushedRoutes).single.isA<DialogRoute<void>>();
pushedRoutes.clear();
check(removedRoutes).single.isA<WidgetRoute>().page.isA<HomePage>();
check(testBinding.globalStore.takeDoRemoveAccountCalls())
.single.equals(eg.selfAccount.id);

await tester.tap(find.byWidget(checkErrorDialog(tester,
expectedTitle: 'Could not connect',
expectedMessage:
'Your account at https://chat.example/ could not be authenticated.'
' Please try logging in again or use another account.')));
// The navigator stack became empty after dismissing the error dialog,
// so a choose-account page route was pushed.
check(pushedRoutes).single.isA<WidgetRoute>().page.isA<ChooseAccountPage>();
});

testWidgets('push route when removing last route on stack', (tester) async {
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
await prepare(tester);
Expand Down

0 comments on commit 2859759

Please sign in to comment.