1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-06 11:43:51 +00:00

[PM-31606] - Clone should not be an option for archived item for non-premium user (#18726)

* do not allow cloning of archived items for non-premium users

* add tests
This commit is contained in:
Jordan Aasen
2026-02-04 09:31:23 -08:00
committed by GitHub
parent a2916084ee
commit 2876ef15ae
3 changed files with 109 additions and 10 deletions

View File

@@ -74,9 +74,11 @@
<button type="button" bitMenuItem (click)="edit(cipher)">
{{ "edit" | i18n }}
</button>
<button type="button" bitMenuItem (click)="clone(cipher)">
{{ "clone" | i18n }}
</button>
@if (userHasPremium$ | async) {
<button type="button" bitMenuItem (click)="clone(cipher)">
{{ "clone" | i18n }}
</button>
}
@if (canAssignCollections$ | async) {
<button
type="button"

View File

@@ -1,4 +1,6 @@
import { TestBed } from "@angular/core/testing";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { provideNoopAnimations } from "@angular/platform-browser/animations";
import { Router } from "@angular/router";
import { mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
@@ -7,10 +9,13 @@ import { CollectionService } from "@bitwarden/admin-console/common";
import { PopupRouterCacheService } from "@bitwarden/browser/platform/popup/view-cache/popup-router-cache.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { DialogService, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
@@ -25,40 +30,78 @@ jest.mock("qrcode-parser", () => {});
describe("ArchiveComponent", () => {
let component: ArchiveComponent;
let fixture: ComponentFixture<ArchiveComponent>;
let hasOrganizations: jest.Mock;
let decryptedCollections$: jest.Mock;
let navigate: jest.Mock;
let showPasswordPrompt: jest.Mock;
let userHasPremium$: jest.Mock;
let archivedCiphers$: jest.Mock;
beforeAll(async () => {
beforeEach(async () => {
navigate = jest.fn();
showPasswordPrompt = jest.fn().mockResolvedValue(true);
hasOrganizations = jest.fn();
decryptedCollections$ = jest.fn();
hasOrganizations = jest.fn().mockReturnValue(of(false));
decryptedCollections$ = jest.fn().mockReturnValue(of([]));
userHasPremium$ = jest.fn().mockReturnValue(of(false));
archivedCiphers$ = jest.fn().mockReturnValue(of([{ id: "cipher-1" }]));
await TestBed.configureTestingModule({
imports: [ArchiveComponent],
providers: [
provideNoopAnimations(),
{ provide: Router, useValue: { navigate } },
{
provide: AccountService,
useValue: { activeAccount$: new BehaviorSubject({ id: "user-id" }) },
},
{ provide: PasswordRepromptService, useValue: { showPasswordPrompt } },
{ provide: OrganizationService, useValue: { hasOrganizations } },
{
provide: OrganizationService,
useValue: { hasOrganizations, organizations$: () => of([]) },
},
{ provide: CollectionService, useValue: { decryptedCollections$ } },
{ provide: DialogService, useValue: mock<DialogService>() },
{ provide: CipherService, useValue: mock<CipherService>() },
{ provide: CipherArchiveService, useValue: mock<CipherArchiveService>() },
{
provide: CipherArchiveService,
useValue: {
userHasPremium$,
archivedCiphers$,
userCanArchive$: jest.fn().mockReturnValue(of(true)),
showSubscriptionEndedMessaging$: jest.fn().mockReturnValue(of(false)),
},
},
{ provide: ToastService, useValue: mock<ToastService>() },
{ provide: PopupRouterCacheService, useValue: mock<PopupRouterCacheService>() },
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
{ provide: LogService, useValue: mock<LogService>() },
{ provide: I18nService, useValue: { t: (key: string) => key } },
{
provide: EnvironmentService,
useValue: {
environment$: of({
getIconsUrl: () => "https://icons.example.com",
}),
},
},
{
provide: DomainSettingsService,
useValue: {
showFavicons$: of(true),
},
},
{
provide: CipherAuthorizationService,
useValue: {
canDeleteCipher$: jest.fn().mockReturnValue(of(true)),
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ArchiveComponent);
fixture = TestBed.createComponent(ArchiveComponent);
component = fixture.componentInstance;
});
@@ -137,4 +180,54 @@ describe("ArchiveComponent", () => {
expect(navigate).not.toHaveBeenCalled();
});
});
describe("clone menu option", () => {
const getBitMenuPanel = () => document.querySelector(".bit-menu-panel");
it("is shown when user has premium", async () => {
userHasPremium$.mockReturnValue(of(true));
const testFixture = TestBed.createComponent(ArchiveComponent);
testFixture.detectChanges();
await testFixture.whenStable();
const menuTrigger = testFixture.debugElement.query(By.css('button[aria-haspopup="menu"]'));
expect(menuTrigger).toBeTruthy();
(menuTrigger.nativeElement as HTMLButtonElement).click();
testFixture.detectChanges();
const menuPanel = getBitMenuPanel();
expect(menuPanel).toBeTruthy();
const menuButtons = menuPanel?.querySelectorAll("button[bitMenuItem]");
const cloneButtonFound = Array.from(menuButtons || []).some(
(btn) => btn.textContent?.trim() === "clone",
);
expect(cloneButtonFound).toBe(true);
});
it("is not shown when user does not have premium", async () => {
userHasPremium$.mockReturnValue(of(false));
const testFixture = TestBed.createComponent(ArchiveComponent);
testFixture.detectChanges();
await testFixture.whenStable();
const menuTrigger = testFixture.debugElement.query(By.css('button[aria-haspopup="menu"]'));
expect(menuTrigger).toBeTruthy();
(menuTrigger.nativeElement as HTMLButtonElement).click();
testFixture.detectChanges();
const menuPanel = getBitMenuPanel();
expect(menuPanel).toBeTruthy();
const menuButtons = menuPanel?.querySelectorAll("button[bitMenuItem]");
const cloneButtonFound = Array.from(menuButtons || []).some(
(btn) => btn.textContent?.trim() === "clone",
);
expect(cloneButtonFound).toBe(false);
});
});
});

View File

@@ -135,6 +135,10 @@ export class ArchiveComponent {
switchMap((userId) => this.cipherArchiveService.showSubscriptionEndedMessaging$(userId)),
);
protected userHasPremium$ = this.userId$.pipe(
switchMap((userId) => this.cipherArchiveService.userHasPremium$(userId)),
);
async navigateToPremium() {
await this.router.navigate(["/premium"]);
}