From f8ca91c8a72baf775712aea5226b94edbeef3f01 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Tue, 6 Jan 2026 12:50:46 -0800 Subject: [PATCH] [PM-25693] - hide import sshKey button for view-only users (#17985) * hide import sshKey button for view-only users * use @if * add optional chain * use computed property. update tests * move comment down --- .../sshkey-section.component.html | 19 +- .../sshkey-section.component.spec.ts | 261 ++++++++++++++++++ .../sshkey-section.component.ts | 28 +- 3 files changed, 283 insertions(+), 25 deletions(-) create mode 100644 libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.spec.ts diff --git a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html index ec9d715ff19..419791125fb 100644 --- a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html +++ b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html @@ -15,15 +15,16 @@ data-testid="toggle-privateKey-visibility" bitPasswordInputToggle > - + @if (showImport()) { + + } diff --git a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.spec.ts b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.spec.ts new file mode 100644 index 00000000000..3f4a7500388 --- /dev/null +++ b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.spec.ts @@ -0,0 +1,261 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, Subject } from "rxjs"; + +import { ClientType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; +import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view"; +import { generate_ssh_key } from "@bitwarden/sdk-internal"; + +import { SshImportPromptService } from "../../../services/ssh-import-prompt.service"; +import { CipherFormContainer } from "../../cipher-form-container"; + +import { SshKeySectionComponent } from "./sshkey-section.component"; + +jest.mock("@bitwarden/sdk-internal", () => { + return { + generate_ssh_key: jest.fn(), + }; +}); + +describe("SshKeySectionComponent", () => { + let fixture: ComponentFixture; + let component: SshKeySectionComponent; + const mockI18nService = mock(); + + let formStatusChange$: Subject; + + let cipherFormContainer: { + registerChildForm: jest.Mock; + patchCipher: jest.Mock; + getInitialCipherView: jest.Mock; + formStatusChange$: Subject; + }; + + let sdkClient$: BehaviorSubject; + let sdkService: { client$: BehaviorSubject }; + + let sshImportPromptService: { importSshKeyFromClipboard: jest.Mock }; + + let platformUtilsService: { getClientType: jest.Mock }; + + beforeEach(async () => { + formStatusChange$ = new Subject(); + + cipherFormContainer = { + registerChildForm: jest.fn(), + patchCipher: jest.fn(), + getInitialCipherView: jest.fn(), + formStatusChange$, + }; + + sdkClient$ = new BehaviorSubject({}); + sdkService = { client$: sdkClient$ }; + + sshImportPromptService = { + importSshKeyFromClipboard: jest.fn(), + }; + + platformUtilsService = { + getClientType: jest.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [SshKeySectionComponent], + providers: [ + { provide: I18nService, useValue: mockI18nService }, + { provide: CipherFormContainer, useValue: cipherFormContainer }, + { provide: SdkService, useValue: sdkService }, + { provide: SshImportPromptService, useValue: sshImportPromptService }, + { provide: PlatformUtilsService, useValue: platformUtilsService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(SshKeySectionComponent); + component = fixture.componentInstance; + + // minimal required inputs + fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null }); + fixture.componentRef.setInput("disabled", false); + + (generate_ssh_key as unknown as jest.Mock).mockReset(); + }); + + it("registers the sshKeyDetails form with the container in the constructor", () => { + expect(cipherFormContainer.registerChildForm).toHaveBeenCalledTimes(1); + expect(cipherFormContainer.registerChildForm).toHaveBeenCalledWith( + "sshKeyDetails", + component.sshKeyForm, + ); + }); + + it("patches cipher sshKey whenever the form changes", () => { + component.sshKeyForm.setValue({ + privateKey: "priv", + publicKey: "pub", + keyFingerprint: "fp", + }); + + expect(cipherFormContainer.patchCipher).toHaveBeenCalledTimes(1); + const patchFn = cipherFormContainer.patchCipher.mock.calls[0][0] as (c: any) => any; + + const cipher: any = {}; + const patched = patchFn(cipher); + + expect(patched.sshKey).toBeInstanceOf(SshKeyView); + expect(patched.sshKey.privateKey).toBe("priv"); + expect(patched.sshKey.publicKey).toBe("pub"); + expect(patched.sshKey.keyFingerprint).toBe("fp"); + }); + + it("ngOnInit uses initial cipher sshKey (prefill) when present and does not generate", async () => { + cipherFormContainer.getInitialCipherView.mockReturnValue({ + sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" }, + }); + + platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop); + + await component.ngOnInit(); + + expect(generate_ssh_key).not.toHaveBeenCalled(); + expect(component.sshKeyForm.get("privateKey")?.value).toBe("p1"); + expect(component.sshKeyForm.get("publicKey")?.value).toBe("p2"); + expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("p3"); + }); + + it("ngOnInit falls back to originalCipherView sshKey when prefill is missing", async () => { + cipherFormContainer.getInitialCipherView.mockReturnValue(null); + fixture.componentRef.setInput("originalCipherView", { + edit: true, + sshKey: { privateKey: "o1", publicKey: "o2", keyFingerprint: "o3" }, + }); + + platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop); + + await component.ngOnInit(); + + expect(generate_ssh_key).not.toHaveBeenCalled(); + expect(component.sshKeyForm.get("privateKey")?.value).toBe("o1"); + expect(component.sshKeyForm.get("publicKey")?.value).toBe("o2"); + expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("o3"); + }); + + it("ngOnInit generates an ssh key when no sshKey exists and populates the form", async () => { + cipherFormContainer.getInitialCipherView.mockReturnValue(null); + fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null }); + + (generate_ssh_key as unknown as jest.Mock).mockReturnValue({ + privateKey: "genPriv", + publicKey: "genPub", + fingerprint: "genFp", + }); + + platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop); + + await component.ngOnInit(); + + expect(generate_ssh_key).toHaveBeenCalledTimes(1); + expect(generate_ssh_key).toHaveBeenCalledWith("Ed25519"); + expect(component.sshKeyForm.get("privateKey")?.value).toBe("genPriv"); + expect(component.sshKeyForm.get("publicKey")?.value).toBe("genPub"); + expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("genFp"); + }); + + it("ngOnInit disables the form", async () => { + cipherFormContainer.getInitialCipherView.mockReturnValue({ + sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" }, + }); + platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop); + + await component.ngOnInit(); + + expect(component.sshKeyForm.disabled).toBe(true); + }); + + it("sets showImport true when not Web and originalCipherView.edit is true", async () => { + cipherFormContainer.getInitialCipherView.mockReturnValue({ + sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" }, + }); + + platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop); + fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null } as any); + + await component.ngOnInit(); + + expect(component.showImport()).toBe(true); + }); + + it("keeps showImport false when client type is Web", async () => { + cipherFormContainer.getInitialCipherView.mockReturnValue({ + sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" }, + }); + + platformUtilsService.getClientType.mockReturnValue(ClientType.Web); + fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null } as any); + + await component.ngOnInit(); + + expect(component.showImport()).toBe(false); + }); + + it("disables the ssh key form when formStatusChange emits enabled", async () => { + cipherFormContainer.getInitialCipherView.mockReturnValue({ + sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" }, + }); + + platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop); + + await component.ngOnInit(); + + component.sshKeyForm.enable(); + expect(component.sshKeyForm.disabled).toBe(false); + + formStatusChange$.next("enabled"); + expect(component.sshKeyForm.disabled).toBe(true); + }); + + it("renders the import button only when showImport is true", async () => { + cipherFormContainer.getInitialCipherView.mockReturnValue({ + sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" }, + }); + + platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop); + fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null } as any); + + await component.ngOnInit(); + fixture.detectChanges(); + + const importBtn = fixture.debugElement.query(By.css('[data-testid="import-privateKey"]')); + expect(importBtn).not.toBeNull(); + }); + + it("importSshKeyFromClipboard sets form values when a key is returned", async () => { + sshImportPromptService.importSshKeyFromClipboard.mockResolvedValue({ + privateKey: "cPriv", + publicKey: "cPub", + keyFingerprint: "cFp", + }); + + await component.importSshKeyFromClipboard(); + + expect(component.sshKeyForm.get("privateKey")?.value).toBe("cPriv"); + expect(component.sshKeyForm.get("publicKey")?.value).toBe("cPub"); + expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("cFp"); + }); + + it("importSshKeyFromClipboard does nothing when null is returned", async () => { + component.sshKeyForm.setValue({ privateKey: "a", publicKey: "b", keyFingerprint: "c" }); + sshImportPromptService.importSshKeyFromClipboard.mockResolvedValue(null); + + await component.importSshKeyFromClipboard(); + + expect(component.sshKeyForm.get("privateKey")?.value).toBe("a"); + expect(component.sshKeyForm.get("publicKey")?.value).toBe("b"); + expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("c"); + }); +}); diff --git a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts index 990de9574ab..32d572cf2f3 100644 --- a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts +++ b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, DestroyRef, inject, Input, OnInit } from "@angular/core"; +import { Component, computed, DestroyRef, inject, input, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { firstValueFrom } from "rxjs"; @@ -43,15 +43,9 @@ import { CipherFormContainer } from "../../cipher-form-container"; ], }) export class SshKeySectionComponent implements OnInit { - /** The original cipher */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() originalCipherView: CipherView; + readonly originalCipherView = input(null); - /** True when all fields should be disabled */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() disabled: boolean; + readonly disabled = input(false); /** * All form fields associated with the ssh key @@ -65,7 +59,14 @@ export class SshKeySectionComponent implements OnInit { keyFingerprint: [""], }); - showImport = false; + readonly showImport = computed(() => { + return ( + // Web does not support clipboard access + this.platformUtilsService.getClientType() !== ClientType.Web && + this.originalCipherView()?.edit + ); + }); + private destroyRef = inject(DestroyRef); constructor( @@ -90,7 +91,7 @@ export class SshKeySectionComponent implements OnInit { async ngOnInit() { const prefillCipher = this.cipherFormContainer.getInitialCipherView(); - const sshKeyView = prefillCipher?.sshKey ?? this.originalCipherView?.sshKey; + const sshKeyView = prefillCipher?.sshKey ?? this.originalCipherView()?.sshKey; if (sshKeyView) { this.setInitialValues(sshKeyView); @@ -100,11 +101,6 @@ export class SshKeySectionComponent implements OnInit { this.sshKeyForm.disable(); - // Web does not support clipboard access - if (this.platformUtilsService.getClientType() !== ClientType.Web) { - this.showImport = true; - } - // Disable the form if the cipher form container is enabled // to prevent user interaction this.cipherFormContainer.formStatusChange$