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

[PM-28514] - fix item copy actions for totp. add specs (#17709)

* fix item copy actions for totp. add specs

* add test to satisfy claude
This commit is contained in:
Jordan Aasen
2025-12-01 13:52:59 -08:00
committed by GitHub
parent e694ab490c
commit 99186e3651
2 changed files with 430 additions and 2 deletions

View File

@@ -0,0 +1,425 @@
import { CommonModule } from "@angular/common";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { of } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { IconButtonModule, ItemModule, MenuModule } from "@bitwarden/components";
import { CipherListView, CopyableCipherFields } from "@bitwarden/sdk-internal";
import { VaultPopupCopyButtonsService } from "../../../services/vault-popup-copy-buttons.service";
import { ItemCopyActionsComponent } from "./item-copy-actions.component";
describe("ItemCopyActionsComponent", () => {
let fixture: ComponentFixture<ItemCopyActionsComponent>;
let component: ItemCopyActionsComponent;
let i18nService: jest.Mocked<I18nService>;
beforeEach(async () => {
i18nService = {
t: jest.fn((key: string) => `translated-${key}`),
} as unknown as jest.Mocked<I18nService>;
await TestBed.configureTestingModule({
imports: [
CommonModule,
JslibModule,
ItemModule,
IconButtonModule,
MenuModule,
ItemCopyActionsComponent, // standalone
],
providers: [
{ provide: I18nService, useValue: i18nService },
{
provide: VaultPopupCopyButtonsService,
useValue: {
showQuickCopyActions$: of(true),
} satisfies Partial<VaultPopupCopyButtonsService>,
},
],
}).compileComponents();
fixture = TestBed.createComponent(ItemCopyActionsComponent);
component = fixture.componentInstance;
// Default cipher so tests can override as needed
component.cipher = {
name: "My cipher",
viewPassword: true,
login: { username: null, password: null, totp: null },
card: { code: null, number: null },
identity: {
fullAddressForCopy: null,
email: null,
username: null,
phone: null,
},
sshKey: {
privateKey: null,
publicKey: null,
keyFingerprint: null,
},
notes: null,
copyableFields: [],
} as unknown as CipherViewLike;
});
afterEach(() => {
jest.restoreAllMocks();
});
describe("findSingleCopyableItem", () => {
beforeEach(() => {
jest
.spyOn(CipherViewLikeUtils, "hasCopyableValue")
.mockImplementation(
(cipher: CipherViewLike & { __copyable?: Record<string, boolean> }, field) => {
return Boolean(cipher.__copyable?.[field]);
},
);
});
it("returns the single item with value and translates its key", () => {
const items = [
{ key: "copyUsername", field: "username" as const },
{ key: "copyPassword", field: "password" as const },
];
(component.cipher as any).__copyable = {
username: true,
password: false,
};
const result = component.findSingleCopyableItem(items);
expect(result).toEqual({
key: "translated-copyUsername",
field: "username",
});
expect(i18nService.t).toHaveBeenCalledWith("copyUsername");
});
it("returns null when no items have a value", () => {
const items = [
{ key: "copyUsername", field: "username" as const },
{ key: "copyPassword", field: "password" as const },
];
(component.cipher as any).__copyable = {
username: false,
password: false,
};
const result = component.findSingleCopyableItem(items);
expect(result).toBeNull();
});
it("returns null when more than one item has a value", () => {
const items = [
{ key: "copyUsername", field: "username" as const },
{ key: "copyPassword", field: "password" as const },
];
(component.cipher as any).__copyable = {
username: true,
password: true,
};
const result = component.findSingleCopyableItem(items);
expect(result).toBeNull();
});
});
describe("singleCopyableLogin", () => {
beforeEach(() => {
jest
.spyOn(CipherViewLikeUtils, "hasCopyableValue")
.mockImplementation(
(cipher: CipherViewLike & { __copyable?: Record<string, boolean> }, field) => {
return Boolean(cipher.__copyable?.[field]);
},
);
});
it("returns username with special-case logic when password is hidden and both username/password exist and no totp", () => {
(component.cipher as CipherView).viewPassword = false;
(component.cipher as any).__copyable = {
username: true,
password: true,
totp: false,
};
const result = component.singleCopyableLogin;
expect(result).toEqual({
key: "translated-copyUsername",
field: "username",
});
expect(i18nService.t).toHaveBeenCalledWith("copyUsername");
});
it("returns null when password is hidden but multiple fields exist, ensuring username and totp are shown in the menu UI ", () => {
(component.cipher as CipherView).viewPassword = false;
(component.cipher as any).__copyable = {
username: true,
password: true,
totp: true,
};
const result = component.singleCopyableLogin;
expect(result).toBeNull();
});
it("falls back to findSingleCopyableItem when password is visible", () => {
const findSingleCopyableItemSpy = jest.spyOn(component, "findSingleCopyableItem");
(component.cipher as CipherView).viewPassword = true;
void component.singleCopyableLogin;
expect(findSingleCopyableItemSpy).toHaveBeenCalled();
});
});
describe("singleCopyableCard", () => {
beforeEach(() => {
jest
.spyOn(CipherViewLikeUtils, "hasCopyableValue")
.mockImplementation(
(cipher: CipherViewLike & { __copyable?: Record<string, boolean> }, field) => {
return Boolean(cipher.__copyable?.[field]);
},
);
});
it("returns security code when it is the only available card value", () => {
(component.cipher as any).__copyable = {
securityCode: true,
cardNumber: false,
};
const result = component.singleCopyableCard;
expect(result).toEqual({
key: "translated-securityCode",
field: "securityCode",
});
expect(i18nService.t).toHaveBeenCalledWith("securityCode");
});
it("returns null when both card number and security code are available", () => {
(component.cipher as any).__copyable = {
securityCode: true,
cardNumber: true,
};
const result = component.singleCopyableCard;
expect(result).toBeNull();
});
});
describe("singleCopyableIdentity", () => {
beforeEach(() => {
jest
.spyOn(CipherViewLikeUtils, "hasCopyableValue")
.mockImplementation(
(cipher: CipherViewLike & { __copyable?: Record<string, boolean> }, field) => {
return Boolean(cipher.__copyable?.[field]);
},
);
});
it("returns the only copyable identity field", () => {
(component.cipher as any).__copyable = {
address: false,
email: true,
username: false,
phone: false,
};
const result = component.singleCopyableIdentity;
expect(result).toEqual({
key: "translated-email",
field: "email",
});
expect(i18nService.t).toHaveBeenCalledWith("email");
});
it("returns null when multiple identity fields are available", () => {
(component.cipher as any).__copyable = {
address: true,
email: true,
username: false,
phone: false,
};
const result = component.singleCopyableIdentity;
expect(result).toBeNull();
});
});
describe("has*Values in non-list view", () => {
beforeEach(() => {
jest.spyOn(CipherViewLikeUtils, "isCipherListView").mockReturnValue(false);
});
it("computes hasLoginValues from login fields", () => {
(component.cipher as CipherView).login = {
username: "user",
password: null,
totp: null,
} as any;
expect(component.hasLoginValues).toBe(true);
(component.cipher as CipherView).login = {
username: null,
password: null,
totp: null,
} as any;
expect(component.hasLoginValues).toBe(false);
});
it("computes hasCardValues from card fields", () => {
(component.cipher as CipherView).card = { code: "123", number: null } as any;
expect(component.hasCardValues).toBe(true);
(component.cipher as CipherView).card = { code: null, number: null } as any;
expect(component.hasCardValues).toBe(false);
});
it("computes hasIdentityValues from identity fields", () => {
(component.cipher as CipherView).identity = {
fullAddressForCopy: null,
email: "test@example.com",
username: null,
phone: null,
} as any;
expect(component.hasIdentityValues).toBe(true);
(component.cipher as CipherView).identity = {
fullAddressForCopy: null,
email: null,
username: null,
phone: null,
} as any;
expect(component.hasIdentityValues).toBe(false);
});
it("computes hasSecureNoteValue from notes", () => {
(component.cipher as CipherView).notes = "Some note" as any;
expect(component.hasSecureNoteValue).toBe(true);
(component.cipher as CipherView).notes = null as any;
expect(component.hasSecureNoteValue).toBe(false);
});
it("computes hasSshKeyValues from sshKey fields", () => {
(component.cipher as CipherView).sshKey = {
privateKey: "priv",
publicKey: null,
keyFingerprint: null,
} as any;
expect(component.hasSshKeyValues).toBe(true);
(component.cipher as CipherView).sshKey = {
privateKey: null,
publicKey: null,
keyFingerprint: null,
} as any;
expect(component.hasSshKeyValues).toBe(false);
});
});
describe("has*Values in list view", () => {
beforeEach(() => {
jest.spyOn(CipherViewLikeUtils, "isCipherListView").mockReturnValue(true);
});
it("uses copyableFields for login values", () => {
(component.cipher as CipherListView).copyableFields = [
"LoginUsername",
"CardNumber",
] as CopyableCipherFields[];
expect(component.hasLoginValues).toBe(true);
(component.cipher as CipherListView).copyableFields = [
"CardNumber",
] as CopyableCipherFields[];
expect(component.hasLoginValues).toBe(false);
});
it("uses copyableFields for card values", () => {
(component.cipher as CipherListView).copyableFields = [
"CardSecurityCode",
] as CopyableCipherFields[];
expect(component.hasCardValues).toBe(true);
(component.cipher as CipherListView).copyableFields = [
"LoginUsername",
] as CopyableCipherFields[];
expect(component.hasCardValues).toBe(false);
});
it("uses copyableFields for identity values", () => {
(component.cipher as CipherListView).copyableFields = [
"IdentityEmail",
] as CopyableCipherFields[];
expect(component.hasIdentityValues).toBe(true);
(component.cipher as CipherListView).copyableFields = [
"LoginUsername",
] as CopyableCipherFields[];
expect(component.hasIdentityValues).toBe(false);
});
it("uses copyableFields for secure note value", () => {
(component.cipher as CipherListView).copyableFields = [
"SecureNotes",
] as CopyableCipherFields[];
expect(component.hasSecureNoteValue).toBe(true);
(component.cipher as CipherListView).copyableFields = [
"LoginUsername",
] as CopyableCipherFields[];
expect(component.hasSecureNoteValue).toBe(false);
});
it("uses copyableFields for ssh key values", () => {
(component.cipher as CipherListView).copyableFields = ["SshKey"] as CopyableCipherFields[];
expect(component.hasSshKeyValues).toBe(true);
(component.cipher as CipherListView).copyableFields = [
"LoginUsername",
] as CopyableCipherFields[];
expect(component.hasSshKeyValues).toBe(false);
});
});
});

View File

@@ -54,17 +54,20 @@ export class ItemCopyActionsComponent {
{ key: "copyPassword", field: "password" },
{ key: "copyVerificationCode", field: "totp" },
];
// If both the password and username are visible but the password is hidden, return the username
// If both the password and username are visible but the password is hidden and there's no
// totp code to copy return the username
if (
!this.cipher.viewPassword &&
CipherViewLikeUtils.hasCopyableValue(this.cipher, "username") &&
CipherViewLikeUtils.hasCopyableValue(this.cipher, "password")
CipherViewLikeUtils.hasCopyableValue(this.cipher, "password") &&
!CipherViewLikeUtils.hasCopyableValue(this.cipher, "totp")
) {
return {
key: this.i18nService.t("copyUsername"),
field: "username" as const,
};
}
return this.findSingleCopyableItem(loginItems);
}