1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 13:23:34 +00:00

[PM-20041] Marking Task as complete (#14980)

* When saving a cipher, mark any associated security tasks as complete

* fix test error from encryption refactor

* hide security tasks that are associated with deleted ciphers (#15247)

* account for deleted ciphers for atRiskPasswordDescriptions
This commit is contained in:
Nick Krantz
2025-06-27 16:04:51 -05:00
committed by GitHub
parent 7a1bb81c5f
commit 700f54357c
5 changed files with 268 additions and 11 deletions

View File

@@ -0,0 +1,161 @@
import { TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import {
CipherService,
EncryptionContext,
} from "@bitwarden/common/vault/abstractions/cipher.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
import { CipherType } from "@bitwarden/sdk-internal";
import { CipherFormConfig } from "../abstractions/cipher-form-config.service";
import { DefaultCipherFormService } from "./default-cipher-form.service";
describe("DefaultCipherFormService", () => {
let service: DefaultCipherFormService;
let testBed: TestBed;
const cipherServiceMock = mock<CipherService>();
let markAsCompleteMock: jest.Mock;
let pendingTasks$: jest.Mock;
beforeEach(() => {
markAsCompleteMock = jest.fn().mockResolvedValue(undefined);
pendingTasks$ = jest.fn().mockReturnValue(of([]));
cipherServiceMock.encrypt.mockResolvedValue({} as EncryptionContext);
testBed = TestBed.configureTestingModule({
providers: [
{ provide: CipherService, useValue: cipherServiceMock },
{ provide: TaskService, useValue: { markAsComplete: markAsCompleteMock, pendingTasks$ } },
{
provide: AccountService,
useValue: { activeAccount$: of({ id: "user-1" as UserId } as Account) },
},
DefaultCipherFormService,
],
});
service = testBed.inject(DefaultCipherFormService);
});
describe("markAssociatedTaskAsComplete", () => {
it("does not call markAsComplete when the cipher is not a login", async () => {
pendingTasks$.mockReturnValueOnce(
of([
{
type: SecurityTaskType.UpdateAtRiskCredential,
cipherId: "cipher-1",
userId: "user-1" as UserId,
},
]),
);
const cardCipher = new CipherView();
cardCipher.type = CipherType.Card;
cardCipher.id = "cipher-1";
await service.saveCipher(cardCipher, {
originalCipher: new Cipher(),
admin: false,
} as CipherFormConfig);
expect(markAsCompleteMock).not.toHaveBeenCalled();
});
it("does not call markAsComplete when there is no associated credential tasks", async () => {
pendingTasks$.mockReturnValueOnce(of([]));
const originalCipher = new Cipher();
originalCipher.type = CipherType.Login;
const cipher = new CipherView();
cipher.type = CipherType.Login;
cipher.id = "cipher-1";
cipher.login = new LoginView();
cipher.login.password = "password123";
cipherServiceMock.decrypt.mockResolvedValue({
...cipher,
login: {
...cipher.login,
password: "newPassword123",
},
} as CipherView);
await service.saveCipher(cipher, {
originalCipher: originalCipher,
admin: false,
} as CipherFormConfig);
expect(markAsCompleteMock).not.toHaveBeenCalled();
});
it("does not call markAsComplete when the password has not changed", async () => {
pendingTasks$.mockReturnValueOnce(
of([
{
type: SecurityTaskType.UpdateAtRiskCredential,
cipherId: "cipher-1",
userId: "user-1" as UserId,
},
]),
);
const cipher = new CipherView();
cipher.type = CipherType.Login;
cipher.id = "cipher-1";
cipher.login = new LoginView();
cipher.login.password = "password123";
cipherServiceMock.decrypt.mockResolvedValue(cipher);
await service.saveCipher(cipher, {
originalCipher: new Cipher(),
admin: false,
} as CipherFormConfig);
expect(markAsCompleteMock).not.toHaveBeenCalled();
});
it("calls markAsComplete when the cipher password has changed and there is an associated credential task", async () => {
pendingTasks$.mockReturnValueOnce(
of([
{
type: SecurityTaskType.UpdateAtRiskCredential,
cipherId: "cipher-1",
userId: "user-1" as UserId,
},
]),
);
const cipher = new CipherView();
cipher.type = CipherType.Login;
cipher.id = "cipher-1";
cipher.login = new LoginView();
cipher.login.password = "password123";
cipherServiceMock.decrypt.mockResolvedValue({
...cipher,
login: {
...cipher.login,
password: "newPassword123",
},
} as CipherView);
await service.saveCipher(cipher, {
originalCipher: new Cipher(),
admin: false,
} as CipherFormConfig);
expect(markAsCompleteMock).toHaveBeenCalled();
});
});
});

View File

@@ -1,13 +1,16 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { inject, Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, map } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
import { CipherFormConfig } from "../abstractions/cipher-form-config.service";
import { CipherFormService } from "../abstractions/cipher-form.service";
@@ -20,6 +23,7 @@ function isSetEqual(a: Set<string>, b: Set<string>) {
export class DefaultCipherFormService implements CipherFormService {
private cipherService: CipherService = inject(CipherService);
private accountService: AccountService = inject(AccountService);
private taskService: TaskService = inject(TaskService);
async decryptCipher(cipher: Cipher): Promise<CipherView> {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
@@ -89,6 +93,8 @@ export class DefaultCipherFormService implements CipherFormService {
}
}
await this.markAssociatedTaskAsComplete(activeUserId, cipher, config);
// Its possible the cipher was made no longer available due to collection assignment changes
// e.g. The cipher was moved to a collection that the user no longer has access to
if (savedCipher == null) {
@@ -97,4 +103,47 @@ export class DefaultCipherFormService implements CipherFormService {
return await this.cipherService.decrypt(savedCipher, activeUserId);
}
/**
* When a cipher has an associated pending `UpdateAtRiskCredential` task
* and the password has changed, mark the task as complete.
*/
private async markAssociatedTaskAsComplete(
userId: UserId,
updatedCipher: CipherView,
config: CipherFormConfig,
) {
const decryptedOriginalCipherCipher = await this.cipherService.decrypt(
config.originalCipher,
userId,
);
const associatedPendingTask = await firstValueFrom(
this.taskService
.pendingTasks$(userId)
.pipe(
map((tasks) =>
tasks.find(
(task) =>
task.type === SecurityTaskType.UpdateAtRiskCredential &&
task.cipherId === updatedCipher.id,
),
),
),
);
const passwordHasChanged =
updatedCipher.type === CipherType.Login &&
updatedCipher.login.password &&
updatedCipher.login.password !== decryptedOriginalCipherCipher?.login?.password;
// When there is not an associated pending task or the password has not changed,
// no action needed-return early.
if (!associatedPendingTask || !passwordHasChanged) {
return;
}
// If the cipher is a login and the password has changed, mark the associated task as complete
await this.taskService.markAsComplete(associatedPendingTask.id, userId);
}
}