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:
12
libs/common/src/vault/abstractions/cipher-archive.service.ts
Normal file
12
libs/common/src/vault/abstractions/cipher-archive.service.ts
Normal 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>;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export abstract class SearchService {
|
||||
ciphers: C[],
|
||||
query: string,
|
||||
deleted?: boolean,
|
||||
archived?: boolean,
|
||||
): C[];
|
||||
abstract searchSends(sends: SendView[], query: string): SendView[];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
122
libs/common/src/vault/services/default-cipher-archive.service.ts
Normal file
122
libs/common/src/vault/services/default-cipher-archive.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user