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:
@@ -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 service’s “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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1169,8 +1169,10 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
// If stored and provided data are identical, this event doesn’t fire and the ciphers$
|
||||
// observable won’t 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);
|
||||
|
||||
Reference in New Issue
Block a user