1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-26681] - ensure initial cipher state is set for empty vaults (#16793)

* don't skip cipher state updates for empty vaults

* add specs and comment

* do not return ciphers, just return

* update spec names
This commit is contained in:
Jordan Aasen
2025-10-08 14:13:59 -07:00
committed by GitHub
parent 2aa0ad672e
commit 46aa3866df
2 changed files with 88 additions and 2 deletions

View File

@@ -45,6 +45,7 @@ import { CipherView } from "../models/view/cipher.view";
import { LoginUriView } from "../models/view/login-uri.view";
import { CipherService } from "./cipher.service";
import { ENCRYPTED_CIPHERS } from "./key-state/ciphers.state";
const ENCRYPTED_TEXT = "This data has been encrypted";
function encryptText(clearText: string | Uint8Array) {
@@ -817,4 +818,87 @@ describe("Cipher Service", () => {
expect(failures).toHaveLength(0);
});
});
describe("replace (no upsert)", () => {
// In order to set up initial state we need to manually update the encrypted state
// which will result in an emission. All tests will have this baseline emission.
const TEST_BASELINE_EMISSIONS = 1;
const makeCipher = (id: string): CipherData =>
({
...cipherData,
id,
name: `Enc ${id}`,
}) as CipherData;
const tick = async () => new Promise((r) => setTimeout(r, 0));
const setEncryptedState = async (data: Record<CipherId, CipherData>, uid = userId) => {
// Directly set the encrypted state, this will result in a single emission
await stateProvider.getUser(uid, ENCRYPTED_CIPHERS).update(() => data);
// match services “next tick” behavior so subscribers see it
await tick();
};
it("emits and calls updateEncryptedCipherState when current state is empty and replace({}) is called", async () => {
// Ensure empty state
await setEncryptedState({});
const emissions: Array<Record<CipherId, CipherData>> = [];
const sub = cipherService.ciphers$(userId).subscribe((v) => emissions.push(v));
await tick();
const spy = jest.spyOn<any, any>(cipherService, "updateEncryptedCipherState");
// Calling replace with empty object MUST still update to trigger init emissions
await cipherService.replace({}, userId);
await tick();
expect(spy).toHaveBeenCalledTimes(1);
expect(emissions.length).toBeGreaterThanOrEqual(TEST_BASELINE_EMISSIONS + 1);
sub.unsubscribe();
});
it("does NOT emit or call updateEncryptedCipherState when state is non-empty and identical", async () => {
const A = makeCipher("A");
await setEncryptedState({ [A.id as CipherId]: A });
const emissions: Array<Record<CipherId, CipherData>> = [];
const sub = cipherService.ciphers$(userId).subscribe((v) => emissions.push(v));
await tick();
const spy = jest.spyOn<any, any>(cipherService, "updateEncryptedCipherState");
// identical snapshot → short-circuit path
await cipherService.replace({ [A.id as CipherId]: A }, userId);
await tick();
expect(spy).not.toHaveBeenCalled();
expect(emissions.length).toBe(TEST_BASELINE_EMISSIONS);
sub.unsubscribe();
});
it("emits and calls updateEncryptedCipherState when the provided state differs from current", async () => {
const A = makeCipher("A");
await setEncryptedState({ [A.id as CipherId]: A });
const emissions: Array<Record<CipherId, CipherData>> = [];
const sub = cipherService.ciphers$(userId).subscribe((v) => emissions.push(v));
await tick();
const spy = jest.spyOn<any, any>(cipherService, "updateEncryptedCipherState");
const B = makeCipher("B");
await cipherService.replace({ [B.id as CipherId]: B }, userId);
await tick();
expect(spy).toHaveBeenCalledTimes(1);
expect(emissions.length).toBeGreaterThanOrEqual(TEST_BASELINE_EMISSIONS + 1);
sub.unsubscribe();
});
});
});

View File

@@ -1169,8 +1169,10 @@ export class CipherService implements CipherServiceAbstraction {
// If stored and provided data are identical, this event doesnt fire and the ciphers$
// observable wont emit a new value. In this case we can skip the update to avoid calling
// clearCache and causing an empty state.
if (JSON.stringify(current) === JSON.stringify(ciphers)) {
return ciphers;
// If the current state is empty (eg. for new users), we still want to perform the update to ensure
// we trigger an emission as many subscribers rely on it during initialization.
if (Object.keys(current).length > 0 && JSON.stringify(current) === JSON.stringify(ciphers)) {
return;
}
await this.updateEncryptedCipherState(() => ciphers, userId);