1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-18 17:23:37 +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

@@ -0,0 +1,12 @@
import { Observable } from "rxjs";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
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>;
}

View File

@@ -30,6 +30,7 @@ export abstract class SearchService {
ciphers: C[],
query: string,
deleted?: boolean,
archived?: boolean,
): C[];
abstract searchSends(sends: SendView[], query: string): SendView[];
}

View File

@@ -0,0 +1,236 @@
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 { CipherListView } from "@bitwarden/sdk-internal";
import { DefaultCipherArchiveService } from "./default-cipher-archive.service";
describe("DefaultCipherArchiveService", () => {
let service: DefaultCipherArchiveService;
let mockCipherService: jest.Mocked<CipherService>;
let mockApiService: jest.Mocked<ApiService>;
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>();
mockBillingAccountProfileStateService = mock<BillingAccountProfileStateService>();
mockConfigService = mock<ConfigService>();
service = new DefaultCipherArchiveService(
mockCipherService,
mockApiService,
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,
);
});
});
});

View File

@@ -0,0 +1,122 @@
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 {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { CipherArchiveService } from "../abstractions/cipher-archive.service";
export class DefaultCipherArchiveService implements CipherArchiveService {
constructor(
private cipherService: CipherService,
private apiService: ApiService,
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);
}
}

View File

@@ -296,12 +296,20 @@ export class SearchService implements SearchServiceAbstraction {
return results;
}
searchCiphersBasic<C extends CipherViewLike>(ciphers: C[], query: string, deleted = false) {
searchCiphersBasic<C extends CipherViewLike>(
ciphers: C[],
query: string,
deleted = false,
archived = false,
) {
query = SearchService.normalizeSearchQuery(query.trim().toLowerCase());
return ciphers.filter((c) => {
if (deleted !== CipherViewLikeUtils.isDeleted(c)) {
return false;
}
if (archived !== CipherViewLikeUtils.isArchived(c)) {
return false;
}
if (c.name != null && c.name.toLowerCase().indexOf(query) > -1) {
return true;
}