1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-02-06 23:51:28 +01:00

PM-16220: Account does not exist during login race condition (#12488)

Wait for an account to become available from separate observable, instead of blindly accepting that the value is there using `firstValueFrom`, while it's sometimes not there immediately.
This commit is contained in:
Maciej Zieniuk 2025-01-27 16:11:42 +01:00 committed by GitHub
parent 0e3e3c16c4
commit 9d987a2513
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 54 additions and 12 deletions

View File

@ -15,6 +15,7 @@
"scripts": { "scripts": {
"clean": "rimraf dist", "clean": "rimraf dist",
"build": "npm run clean && tsc", "build": "npm run clean && tsc",
"build:watch": "npm run clean && tsc -watch" "build:watch": "npm run clean && tsc -watch",
"test": "jest"
} }
} }

View File

@ -69,6 +69,7 @@ describe("accountService", () => {
let sut: AccountServiceImplementation; let sut: AccountServiceImplementation;
let accountsState: FakeGlobalState<Record<UserId, AccountInfo>>; let accountsState: FakeGlobalState<Record<UserId, AccountInfo>>;
let activeAccountIdState: FakeGlobalState<UserId>; let activeAccountIdState: FakeGlobalState<UserId>;
let accountActivityState: FakeGlobalState<Record<UserId, Date>>;
const userId = Utils.newGuid() as UserId; const userId = Utils.newGuid() as UserId;
const userInfo = { email: "email", name: "name", emailVerified: true }; const userInfo = { email: "email", name: "name", emailVerified: true };
@ -81,6 +82,7 @@ describe("accountService", () => {
accountsState = globalStateProvider.getFake(ACCOUNT_ACCOUNTS); accountsState = globalStateProvider.getFake(ACCOUNT_ACCOUNTS);
activeAccountIdState = globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID); activeAccountIdState = globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID);
accountActivityState = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
}); });
afterEach(() => { afterEach(() => {
@ -256,6 +258,7 @@ describe("accountService", () => {
beforeEach(() => { beforeEach(() => {
accountsState.stateSubject.next({ [userId]: userInfo }); accountsState.stateSubject.next({ [userId]: userInfo });
activeAccountIdState.stateSubject.next(userId); activeAccountIdState.stateSubject.next(userId);
accountActivityState.stateSubject.next({ [userId]: new Date(1) });
}); });
it("should emit null if no account is provided", async () => { it("should emit null if no account is provided", async () => {
@ -269,6 +272,34 @@ describe("accountService", () => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
expect(sut.switchAccount("unknown" as UserId)).rejects.toThrowError("Account does not exist"); expect(sut.switchAccount("unknown" as UserId)).rejects.toThrowError("Account does not exist");
}); });
it("should change active account when switched to the new account", async () => {
const newUserId = Utils.newGuid() as UserId;
accountsState.stateSubject.next({ [newUserId]: userInfo });
await sut.switchAccount(newUserId);
await expect(firstValueFrom(sut.activeAccount$)).resolves.toEqual({
id: newUserId,
...userInfo,
});
await expect(firstValueFrom(sut.accountActivity$)).resolves.toEqual({
[userId]: new Date(1),
[newUserId]: expect.toAlmostEqual(new Date(), 1000),
});
});
it("should not change active account when already switched to the same account", async () => {
await sut.switchAccount(userId);
await expect(firstValueFrom(sut.activeAccount$)).resolves.toEqual({
id: userId,
...userInfo,
});
await expect(firstValueFrom(sut.accountActivity$)).resolves.toEqual({
[userId]: new Date(1),
});
});
}); });
describe("account activity", () => { describe("account activity", () => {

View File

@ -7,6 +7,9 @@ import {
shareReplay, shareReplay,
combineLatest, combineLatest,
Observable, Observable,
filter,
timeout,
of,
} from "rxjs"; } from "rxjs";
import { import {
@ -149,21 +152,28 @@ export class AccountServiceImplementation implements InternalAccountService {
async switchAccount(userId: UserId | null): Promise<void> { async switchAccount(userId: UserId | null): Promise<void> {
let updateActivity = false; let updateActivity = false;
await this.activeAccountIdState.update( await this.activeAccountIdState.update(
(_, accounts) => { (_, __) => {
if (userId == null) {
// indicates no account is active
return null;
}
if (accounts?.[userId] == null) {
throw new Error("Account does not exist");
}
updateActivity = true; updateActivity = true;
return userId; return userId;
}, },
{ {
combineLatestWith: this.accounts$, combineLatestWith: this.accountsState.state$.pipe(
shouldUpdate: (id) => { filter((accounts) => {
if (userId == null) {
// Don't worry about accounts when we are about to set active user to null
return true;
}
return accounts?.[userId] != null;
}),
// If we don't get the desired account with enough time, just return empty as that will result in the same error
timeout({ first: 1000, with: () => of({} as Record<UserId, AccountInfo>) }),
),
shouldUpdate: (id, accounts) => {
if (userId != null && accounts?.[userId] == null) {
throw new Error("Account does not exist");
}
// update only if userId changes // update only if userId changes
return id !== userId; return id !== userId;
}, },