Skip to content

Commit 49ed2ef

Browse files
chrisbobbegnprice
authored andcommitted
accounts: Invariant that active account exists when we expect it to
In its handling of the actions REGISTER_COMPLETE, LOGOUT, DISMISS_SERVER_PUSH_SETUP_NOTICE, and EVENT, the `accounts` reducer has been making the unchecked assumption that there is an active account. When there isn't an active account (perhaps because the last account was removed with ACCOUNT_REMOVE), the reducer has been creating badly typed partial `Account`s, by creating an object from a spread of `state[0]` which is `undefined`. For example, with REGISTER_COMPLETE: { ...state[0], userId: action.data.user_id, zulipFeatureLevel: action.data.zulip_feature_level, zulipVersion: action.data.zulip_version, lastDismissedServerPushSetupNotice: action.data.realm_push_notifications_enabled ? null : state[0].lastDismissedServerPushSetupNotice, } That will make an object with only userId, zulipFeatureLevel, zulipVersion, and lastDismissedServerPushSetupNotice. When code expects a well-formed Account including realm, apiKey, email, and ackedPushToken, it'll error. Flow has basically let this happen because it assumes (wrongly, in this case) that `state[0]` is an in-bounds access that will give an Account. We've had a few mysterious Sentry reports where it seems `realm` is missing in the active account. E.g., for those with access: https://sentry.io/organizations/zulip/issues/3900597161/?project=191284 https://sentry.io/organizations/zulip/issues/3900597257/?project=191284 https://sentry.io/organizations/zulip/issues/3900597246/?project=191284 https://sentry.io/organizations/zulip/issues/3900596985/?project=191284 Seeing that the faulty reducer code could cause that symptom, add an `invariant` that there is an active account. (And fix some mute-model test data where the data was unrepresentative because it was missing an active account.) N.B.: The new invariant ensures that there *is* an active account, but it doesn't check that it's the account the action was supposed to act on. We should fix that (so, add TODOs about it), but at least now we hope to be rid of these malformed `Account` objects. Not seeing anything else that could cause these malformed Account objects, add a migration so they don't come up from storage.
1 parent d52fe78 commit 49ed2ef

File tree

4 files changed

+76
-24
lines changed

4 files changed

+76
-24
lines changed

src/account/accountsReducer.js

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,22 +27,29 @@ const findAccount = (state: AccountsState, identity: Identity): number => {
2727
);
2828
};
2929

30+
function updateActiveAccount(state, change) {
31+
const activeAccount: Account | void = state[0];
32+
invariant(activeAccount, 'accounts reducer: expected active account');
33+
34+
return [{ ...activeAccount, ...change }, ...state.slice(1)];
35+
}
36+
3037
// eslint-disable-next-line default-param-last
3138
export default (state: AccountsState = initialState, action: Action): AccountsState => {
3239
switch (action.type) {
3340
case REGISTER_COMPLETE:
34-
return [
35-
{
36-
...state[0],
37-
userId: action.data.user_id,
38-
zulipFeatureLevel: action.data.zulip_feature_level,
39-
zulipVersion: action.data.zulip_version,
40-
lastDismissedServerPushSetupNotice: action.data.realm_push_notifications_enabled
41-
? null
42-
: state[0].lastDismissedServerPushSetupNotice,
43-
},
44-
...state.slice(1),
45-
];
41+
// TODO(#5009): Before implementing a multi-account schema (#5006),
42+
// this will be the wrong account if the active account changed
43+
// while the register was in progress. After #5006, this per-account
44+
// action should naturally act on its own per-account state.
45+
return updateActiveAccount(state, {
46+
userId: action.data.user_id,
47+
zulipFeatureLevel: action.data.zulip_feature_level,
48+
zulipVersion: action.data.zulip_version,
49+
lastDismissedServerPushSetupNotice: action.data.realm_push_notifications_enabled
50+
? null
51+
: state[0].lastDismissedServerPushSetupNotice,
52+
});
4653

4754
case ACCOUNT_SWITCH: {
4855
const index = state.findIndex(
@@ -108,11 +115,15 @@ export default (state: AccountsState = initialState, action: Action): AccountsSt
108115
}
109116

110117
case LOGOUT: {
111-
return [{ ...state[0], apiKey: '', ackedPushToken: null }, ...state.slice(1)];
118+
// TODO: This will be the wrong account if the active account changed
119+
// between pressing "logout" and confirming with the confirmation
120+
// dialog. Fix, perhaps by having the LOGOUT action include an
121+
// Identity in its payload.
122+
return updateActiveAccount(state, { apiKey: '', ackedPushToken: null });
112123
}
113124

114125
case DISMISS_SERVER_PUSH_SETUP_NOTICE: {
115-
return [{ ...state[0], lastDismissedServerPushSetupNotice: action.date }, ...state.slice(1)];
126+
return updateActiveAccount(state, { lastDismissedServerPushSetupNotice: action.date });
116127
}
117128

118129
case ACCOUNT_REMOVE: {
@@ -135,14 +146,10 @@ export default (state: AccountsState = initialState, action: Action): AccountsSt
135146

136147
// TODO: Detect if the feature level has changed, indicating an upgrade;
137148
// if so, trigger a full refetch of server data. See #4793.
138-
return [
139-
{
140-
...state[0],
141-
zulipVersion: new ZulipVersion(zulip_version),
142-
zulipFeatureLevel: zulip_feature_level,
143-
},
144-
...state.slice(1),
145-
];
149+
return updateActiveAccount(state, {
150+
zulipVersion: new ZulipVersion(zulip_version),
151+
zulipFeatureLevel: zulip_feature_level,
152+
});
146153
}
147154
default:
148155
return state;

src/mute/__tests__/muteModel-test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe('reducer', () => {
2525
subscriptions: [eg.subscription],
2626
muted_topics: [[eg.stream.name, 'topic']],
2727
});
28-
const newState = tryGetActiveAccountState(fullReducer(eg.baseReduxState, action));
28+
const newState = tryGetActiveAccountState(fullReducer(eg.plusReduxState, action));
2929
expect(newState).toBeTruthy();
3030
expect(newState && getMute(newState)).toEqual(makeMuteState([[eg.stream, 'topic']]));
3131
});

src/storage/__tests__/migrations-test.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { historicalStoreKeys, migrations } from '../migrations';
55
import { storeKeys } from '../../boot/store';
66
import { createMigrationFunction } from '../../redux-persist-migrate';
77
import { ZulipVersion } from '../../utils/zulipVersion';
8+
import * as eg from '../../__tests__/lib/exampleData';
89

910
describe('historicalStoreKeys', () => {
1011
test('equals current storeKeys', () => {
@@ -104,7 +105,7 @@ describe('migrations', () => {
104105
// What `base` becomes after all migrations.
105106
const endBase = {
106107
...base52,
107-
migrations: { version: 57 },
108+
migrations: { version: 58 },
108109
};
109110

110111
for (const [desc, before, after] of [
@@ -283,6 +284,23 @@ describe('migrations', () => {
283284
{ ...base52, migrations: { version: 56 }, accounts: [...base37.accounts, undefined] },
284285
{ ...endBase, accounts: [...base37.accounts] },
285286
],
287+
[
288+
'check 58 with a malformed Account in state.accounts',
289+
{
290+
...base52,
291+
migrations: { version: 57 },
292+
accounts: [
293+
{
294+
userId: eg.selfUser.user_id,
295+
zulipFeatureLevel: eg.recentZulipFeatureLevel,
296+
zulipVersion: eg.recentZulipVersion,
297+
lastDismissedServerPushSetupNotice: null,
298+
},
299+
...base37.accounts,
300+
],
301+
},
302+
{ ...endBase, accounts: [...base37.accounts] },
303+
],
286304
]) {
287305
/* eslint-disable no-loop-func */
288306
test(desc, async () => {

src/storage/migrations.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,33 @@ const migrationsInner: {| [string]: (LessPartialState) => LessPartialState |} =
475475
// Fix tiny case where an `undefined` could sneak in on ACCOUNT_SWITCH
476476
'57': state => ({ ...state, accounts: state.accounts.filter(Boolean) }),
477477

478+
// Remove malformed partial `Account`s. Perhaps we could salvage some, if
479+
// they're just missing e.g. `zulipFeatureLevel`… but we don't think the
480+
// bug affected many people, and when it did, it quickly followed a state
481+
// where `accounts` was empty, probably because the user asked to remove
482+
// the last account.
483+
'58': state => ({
484+
...state,
485+
accounts: state.accounts.filter(account =>
486+
[
487+
'realm',
488+
'apiKey',
489+
'email',
490+
'userId',
491+
'zulipVersion',
492+
'zulipFeatureLevel',
493+
'ackedPushToken',
494+
'lastDismissedServerPushSetupNotice',
495+
].every(key =>
496+
/* $FlowIgnore[method-unbinding]: This is the standard way to call
497+
`hasOwnProperty`. See discussion:
498+
https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/Flow.20158.20errors/near/1375563
499+
*/
500+
Object.prototype.hasOwnProperty.call(account, key),
501+
),
502+
),
503+
}),
504+
478505
// TIP: When adding a migration, consider just using `dropCache`.
479506
// (See its jsdoc for guidance on when that's the right answer.)
480507
};

0 commit comments

Comments
 (0)