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

[PM-24534] Archive via CLI (#16502)

* refactor `canInteract` into a component level usage.

- The default service is going to be used in the CLI which won't make use of the UI-related aspects

* all nested entities to be imported from the vault

* initial add of archive command to the cli

* add archive to oss serve

* check for deleted cipher when attempting to archive

* add searchability/list functionality for archived ciphers

* restore an archived cipher

* unarchive a cipher when a user is editing it and has lost their premium status

* add missing feature flags

* re-export only needed services from the vault

* add needed await

* add prompt when applicable for editing an archived cipher

* move cipher archive service into `common/vault`

* fix testing code
This commit is contained in:
Nick Krantz
2025-09-30 09:45:04 -05:00
committed by GitHub
parent 7848b7d480
commit 727689d827
27 changed files with 401 additions and 131 deletions

View File

@@ -1,14 +0,0 @@
import { Observable } from "rxjs";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
export abstract class CipherArchiveService {
abstract archivedCiphers$(userId: UserId): Observable<CipherViewLike[]>;
abstract userCanArchive$(userId: UserId): Observable<boolean>;
abstract showArchiveVault$(userId: UserId): Observable<boolean>;
abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
abstract canInteract(cipher: CipherView): Promise<boolean>;
}

View File

@@ -27,5 +27,3 @@ export { SshImportPromptService } from "./services/ssh-import-prompt.service";
export * from "./abstractions/change-login-password.service";
export * from "./services/default-change-login-password.service";
export * from "./abstractions/cipher-archive.service";
export * from "./services/default-cipher-archive.service";

View File

@@ -1,289 +0,0 @@
import { mock } from "jest-mock-extended";
import { of, firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import {
CipherBulkArchiveRequest,
CipherBulkUnarchiveRequest,
} from "@bitwarden/common/vault/models/request/cipher-bulk-archive.request";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
import { CipherListView } from "@bitwarden/sdk-internal";
import { DecryptionFailureDialogComponent } from "../components/decryption-failure-dialog/decryption-failure-dialog.component";
import { DefaultCipherArchiveService } from "./default-cipher-archive.service";
import { PasswordRepromptService } from "./password-reprompt.service";
describe("DefaultCipherArchiveService", () => {
let service: DefaultCipherArchiveService;
let mockCipherService: jest.Mocked<CipherService>;
let mockApiService: jest.Mocked<ApiService>;
let mockDialogService: jest.Mocked<DialogService>;
let mockPasswordRepromptService: jest.Mocked<PasswordRepromptService>;
let mockBillingAccountProfileStateService: jest.Mocked<BillingAccountProfileStateService>;
let mockConfigService: jest.Mocked<ConfigService>;
const userId = "user-id" as UserId;
const cipherId = "123" as CipherId;
beforeEach(() => {
mockCipherService = mock<CipherService>();
mockApiService = mock<ApiService>();
mockDialogService = mock<DialogService>();
mockPasswordRepromptService = mock<PasswordRepromptService>();
mockBillingAccountProfileStateService = mock<BillingAccountProfileStateService>();
mockConfigService = mock<ConfigService>();
service = new DefaultCipherArchiveService(
mockCipherService,
mockApiService,
mockDialogService,
mockPasswordRepromptService,
mockBillingAccountProfileStateService,
mockConfigService,
);
});
describe("archivedCiphers$", () => {
it("should return only archived ciphers", async () => {
const mockCiphers: CipherListView[] = [
{
id: "1",
archivedDate: "2024-01-15T10:30:00.000Z",
type: "identity",
} as unknown as CipherListView,
{
id: "2",
type: "secureNote",
} as unknown as CipherListView,
{
id: "3",
archivedDate: "2024-01-15T10:30:00.000Z",
deletedDate: "2024-01-16T10:30:00.000Z",
type: "sshKey",
} as unknown as CipherListView,
];
mockCipherService.cipherListViews$.mockReturnValue(of(mockCiphers));
const result = await firstValueFrom(service.archivedCiphers$(userId));
expect(result).toHaveLength(1);
expect(result[0].id).toEqual("1");
});
it("should return empty array when no archived ciphers exist", async () => {
const mockCiphers: CipherListView[] = [
{
id: "1",
type: "identity",
} as unknown as CipherListView,
];
mockCipherService.cipherListViews$.mockReturnValue(of(mockCiphers));
const result = await firstValueFrom(service.archivedCiphers$(userId));
expect(result).toHaveLength(0);
});
});
describe("userCanArchive$", () => {
it("should return true when user has premium and feature flag is enabled", async () => {
mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true));
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
const result = await firstValueFrom(service.userCanArchive$(userId));
expect(result).toBe(true);
expect(mockBillingAccountProfileStateService.hasPremiumFromAnySource$).toHaveBeenCalledWith(
userId,
);
expect(mockConfigService.getFeatureFlag$).toHaveBeenCalledWith(
FeatureFlag.PM19148_InnovationArchive,
);
});
it("should return false when feature flag is disabled", async () => {
mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
const result = await firstValueFrom(service.userCanArchive$(userId));
expect(result).toBe(false);
});
});
describe("archiveWithServer", () => {
const mockResponse = {
data: [
{
id: cipherId,
archivedDate: "2024-01-15T10:30:00.000Z",
revisionDate: "2024-01-15T10:31:00.000Z",
},
],
};
beforeEach(() => {
mockApiService.send.mockResolvedValue(mockResponse);
mockCipherService.ciphers$.mockReturnValue(
of({
[cipherId]: {
id: cipherId,
revisionDate: "2024-01-15T10:00:00.000Z",
} as any,
}),
);
mockCipherService.replace.mockResolvedValue(undefined);
});
it("should archive single cipher", async () => {
await service.archiveWithServer(cipherId, userId);
expect(mockApiService.send).toHaveBeenCalledWith(
"PUT",
"/ciphers/archive",
expect.any(CipherBulkArchiveRequest),
true,
true,
);
expect(mockCipherService.ciphers$).toHaveBeenCalledWith(userId);
expect(mockCipherService.replace).toHaveBeenCalledWith(
expect.objectContaining({
[cipherId]: expect.objectContaining({
archivedDate: "2024-01-15T10:30:00.000Z",
revisionDate: "2024-01-15T10:31:00.000Z",
}),
}),
userId,
);
});
it("should archive multiple ciphers", async () => {
const cipherIds = [cipherId, "cipher-id-2" as CipherId];
await service.archiveWithServer(cipherIds, userId);
expect(mockApiService.send).toHaveBeenCalledWith(
"PUT",
"/ciphers/archive",
expect.objectContaining({
ids: cipherIds,
}),
true,
true,
);
});
});
describe("unarchiveWithServer", () => {
const mockResponse = {
data: [
{
id: cipherId,
revisionDate: "2024-01-15T10:31:00.000Z",
},
],
};
beforeEach(() => {
mockApiService.send.mockResolvedValue(mockResponse);
mockCipherService.ciphers$.mockReturnValue(
of({
[cipherId]: {
id: cipherId,
archivedDate: "2024-01-15T10:30:00.000Z",
revisionDate: "2024-01-15T10:00:00.000Z",
} as any,
}),
);
mockCipherService.replace.mockResolvedValue(undefined);
});
it("should unarchive single cipher", async () => {
await service.unarchiveWithServer(cipherId, userId);
expect(mockApiService.send).toHaveBeenCalledWith(
"PUT",
"/ciphers/unarchive",
expect.any(CipherBulkUnarchiveRequest),
true,
true,
);
expect(mockCipherService.ciphers$).toHaveBeenCalledWith(userId);
expect(mockCipherService.replace).toHaveBeenCalledWith(
expect.objectContaining({
[cipherId]: expect.objectContaining({
revisionDate: "2024-01-15T10:31:00.000Z",
}),
}),
userId,
);
});
it("should unarchive multiple ciphers", async () => {
const cipherIds = [cipherId, "cipher-id-2" as CipherId];
await service.unarchiveWithServer(cipherIds, userId);
expect(mockApiService.send).toHaveBeenCalledWith(
"PUT",
"/ciphers/unarchive",
expect.objectContaining({
ids: cipherIds,
}),
true,
true,
);
});
});
describe("canInteract", () => {
let mockCipherView: CipherView;
beforeEach(() => {
mockCipherView = {
id: cipherId,
decryptionFailure: false,
} as unknown as CipherView;
});
it("should return false and open dialog when cipher has decryption failure", async () => {
mockCipherView.decryptionFailure = true;
const openSpy = jest.spyOn(DecryptionFailureDialogComponent, "open").mockImplementation();
const result = await service.canInteract(mockCipherView);
expect(result).toBe(false);
expect(openSpy).toHaveBeenCalledWith(mockDialogService, {
cipherIds: [cipherId],
});
});
it("should return password reprompt result when no decryption failure", async () => {
mockPasswordRepromptService.passwordRepromptCheck.mockResolvedValue(true);
const result = await service.canInteract(mockCipherView);
expect(result).toBe(true);
expect(mockPasswordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(
mockCipherView,
);
});
it("should return false when password reprompt fails", async () => {
mockPasswordRepromptService.passwordRepromptCheck.mockResolvedValue(false);
const result = await service.canInteract(mockCipherView);
expect(result).toBe(false);
});
});
});

View File

@@ -1,145 +0,0 @@
import { filter, map, Observable, shareReplay, combineLatest, firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import {
CipherBulkArchiveRequest,
CipherBulkUnarchiveRequest,
} from "@bitwarden/common/vault/models/request/cipher-bulk-archive.request";
import { CipherResponse } from "@bitwarden/common/vault/models/response/cipher.response";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { DialogService } from "@bitwarden/components";
import { CipherArchiveService } from "../abstractions/cipher-archive.service";
import { DecryptionFailureDialogComponent } from "../components/decryption-failure-dialog/decryption-failure-dialog.component";
import { PasswordRepromptService } from "./password-reprompt.service";
export class DefaultCipherArchiveService implements CipherArchiveService {
constructor(
private cipherService: CipherService,
private apiService: ApiService,
private dialogService: DialogService,
private passwordRepromptService: PasswordRepromptService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private configService: ConfigService,
) {}
/**
* Observable that contains the list of ciphers that have been archived.
*/
archivedCiphers$(userId: UserId): Observable<CipherViewLike[]> {
return this.cipherService.cipherListViews$(userId).pipe(
filter((cipher) => cipher != null),
map((ciphers) =>
ciphers.filter(
(cipher) =>
CipherViewLikeUtils.isArchived(cipher) && !CipherViewLikeUtils.isDeleted(cipher),
),
),
);
}
/**
* User can archive items if:
* Feature Flag is enabled
* User has premium from any source (personal or organization)
*/
userCanArchive$(userId: UserId): Observable<boolean> {
return combineLatest([
this.billingAccountProfileStateService.hasPremiumFromAnySource$(userId),
this.configService.getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive),
]).pipe(
map(([hasPremium, archiveFlagEnabled]) => hasPremium && archiveFlagEnabled),
shareReplay({ refCount: true, bufferSize: 1 }),
);
}
/**
* User can access the archive vault if:
* Feature Flag is enabled
* There is at least one archived item
* ///////////// NOTE /////////////
* This is separated from userCanArchive because a user that loses premium status, but has archived items,
* should still be able to access their archive vault. The items will be read-only, and can be restored.
*/
showArchiveVault$(userId: UserId): Observable<boolean> {
return combineLatest([
this.configService.getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive),
this.archivedCiphers$(userId),
]).pipe(
map(
([archiveFlagEnabled, hasArchivedItems]) =>
archiveFlagEnabled && hasArchivedItems.length > 0,
),
shareReplay({ refCount: true, bufferSize: 1 }),
);
}
async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void> {
const request = new CipherBulkArchiveRequest(Array.isArray(ids) ? ids : [ids]);
const r = await this.apiService.send("PUT", "/ciphers/archive", request, true, true);
const response = new ListResponse(r, CipherResponse);
const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId));
for (const cipher of response.data) {
const localCipher = currentCiphers[cipher.id as CipherId];
if (localCipher == null) {
continue;
}
localCipher.archivedDate = cipher.archivedDate;
localCipher.revisionDate = cipher.revisionDate;
}
await this.cipherService.replace(currentCiphers, userId);
}
async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void> {
const request = new CipherBulkUnarchiveRequest(Array.isArray(ids) ? ids : [ids]);
const r = await this.apiService.send("PUT", "/ciphers/unarchive", request, true, true);
const response = new ListResponse(r, CipherResponse);
const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId));
for (const cipher of response.data) {
const localCipher = currentCiphers[cipher.id as CipherId];
if (localCipher == null) {
continue;
}
localCipher.archivedDate = cipher.archivedDate;
localCipher.revisionDate = cipher.revisionDate;
}
await this.cipherService.replace(currentCiphers, userId);
}
/**
* Check if the user is able to interact with the cipher
* (password re-prompt / decryption failure checks).
* @param cipher
* @private
*/
async canInteract(cipher: CipherView) {
if (cipher.decryptionFailure) {
DecryptionFailureDialogComponent.open(this.dialogService, {
cipherIds: [cipher.id as CipherId],
});
return false;
}
return await this.passwordRepromptService.passwordRepromptCheck(cipher);
}
}