diff --git a/TestHarnessShared/Core/Autofill/Passkey/DefaultFido2CredentialStore.swift b/TestHarnessShared/Core/Autofill/Passkey/DefaultFido2CredentialStore.swift index 15172f7a2c..5f8d612edd 100644 --- a/TestHarnessShared/Core/Autofill/Passkey/DefaultFido2CredentialStore.swift +++ b/TestHarnessShared/Core/Autofill/Passkey/DefaultFido2CredentialStore.swift @@ -49,6 +49,16 @@ actor DefaultFido2CredentialStore: Fido2CredentialStore { try await vaultClientService.ciphers().decryptList(ciphers: ciphers) } + /// Deletes the credential backed by the cipher with the given ID, if one exists, and persists + /// the updated list via the injected `CipherStorageService`. + /// + /// - Parameter cipherId: The ID of the cipher to delete. + /// + func deleteCredential(cipherId: String) { + ciphers.removeAll { $0.id == cipherId } + cipherStorageService.save(ciphers: ciphers) + } + func findCredentials(ids: [Data]?, ripId: String, userHandle: Data?) async throws -> [CipherView] { var matches: [CipherView] = [] for cipher in ciphers { diff --git a/TestHarnessShared/Core/Autofill/Passkey/DefaultFido2CredentialStoreTests.swift b/TestHarnessShared/Core/Autofill/Passkey/DefaultFido2CredentialStoreTests.swift index b6aeb9ee8f..41acebf633 100644 --- a/TestHarnessShared/Core/Autofill/Passkey/DefaultFido2CredentialStoreTests.swift +++ b/TestHarnessShared/Core/Autofill/Passkey/DefaultFido2CredentialStoreTests.swift @@ -53,6 +53,33 @@ class DefaultFido2CredentialStoreTests: BitwardenTestCase { XCTAssertTrue(result.isEmpty) } + /// `deleteCredential(cipherId:)` removes the matching cipher and persists the updated list via + /// the injected `CipherStorageService`. + func test_deleteCredential_removesMatchingCipher() async throws { + let first = Cipher(cipherView: .fixture(id: "cipher-1", name: "First")) + let second = Cipher(cipherView: .fixture(id: "cipher-2", name: "Second")) + try await subject.saveCredential(cred: EncryptionContext(encryptedFor: "1", cipher: first)) + try await subject.saveCredential(cred: EncryptionContext(encryptedFor: "1", cipher: second)) + + await subject.deleteCredential(cipherId: "cipher-1") + + let result = try await subject.allCredentials() + XCTAssertEqual(result.map(\.name), ["Second"]) + XCTAssertEqual(cipherStorageService.saveReceivedCiphers, [second]) + } + + /// `deleteCredential(cipherId:)` leaves the list unchanged when no cipher matches the given + /// ID. + func test_deleteCredential_noMatch_leavesListUnchanged() async throws { + let cipher = Cipher(cipherView: .fixture(id: "cipher-1", name: "Only")) + try await subject.saveCredential(cred: EncryptionContext(encryptedFor: "1", cipher: cipher)) + + await subject.deleteCredential(cipherId: "nonexistent") + + let result = try await subject.allCredentials() + XCTAssertEqual(result.map(\.name), ["Only"]) + } + /// `findCredentials(ids:ripId:userHandle:)` excludes ciphers whose Fido2 credentials don't /// match the requested relying party. func test_findCredentials_noMatch_returnsEmpty() async throws { diff --git a/TestHarnessShared/Core/Autofill/Passkey/PasskeyService.swift b/TestHarnessShared/Core/Autofill/Passkey/PasskeyService.swift index c4e25d048c..ec875c6ae7 100644 --- a/TestHarnessShared/Core/Autofill/Passkey/PasskeyService.swift +++ b/TestHarnessShared/Core/Autofill/Passkey/PasskeyService.swift @@ -22,6 +22,13 @@ public protocol PasskeyService: AnyObject { /// func assertPasskey(credentialId: Data?, rpId: String) async throws -> GetAssertionResult + /// Deletes a previously registered credential. + /// + /// - Parameter cipherId: The ID of the cipher — from `Fido2CredentialAutofillView.cipherId` — + /// backing the credential to delete. + /// + func deleteCredential(cipherId: String) async throws + /// Lists the credentials registered so far, across app launches. /// /// - Returns: The registered credentials' autofill-ready metadata. @@ -129,6 +136,11 @@ actor DefaultPasskeyService: PasskeyService { .getAssertion(request: request) } + func deleteCredential(cipherId: String) async throws { + let (_, credentialStore) = try await session() + await credentialStore.deleteCredential(cipherId: cipherId) + } + func registeredCredentials() async throws -> [Fido2CredentialAutofillView] { let (client, credentialStore) = try await session() return try await client.platform().fido2() diff --git a/TestHarnessShared/Core/Autofill/Passkey/PasskeyServiceTests.swift b/TestHarnessShared/Core/Autofill/Passkey/PasskeyServiceTests.swift index 1a140c891f..1dd35f8410 100644 --- a/TestHarnessShared/Core/Autofill/Passkey/PasskeyServiceTests.swift +++ b/TestHarnessShared/Core/Autofill/Passkey/PasskeyServiceTests.swift @@ -77,6 +77,22 @@ class PasskeyServiceTests: BitwardenTestCase { XCTAssertEqual(assertion.selectedCredential.credential.userName, "user2@example.com") } + /// `deleteCredential(cipherId:)` removes a registered credential so it's no longer listed. + func test_deleteCredential_removesRegisteredCredential() async throws { + _ = try await subject.registerPasskey( + rpId: "bitwarden.com", + userName: "user@example.com", + displayName: "User", + ) + let registered = try await subject.registeredCredentials() + let cipherId = try XCTUnwrap(registered.first?.cipherId) + + try await subject.deleteCredential(cipherId: cipherId) + + let credentials = try await subject.registeredCredentials() + XCTAssertTrue(credentials.isEmpty) + } + /// `registerPasskey(rpId:userName:displayName:)` returns a non-empty credential ID and /// attestation object. func test_registerPasskey_returnsCredential() async throws { diff --git a/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyEffect.swift b/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyEffect.swift index 77874ce83d..77caaa50d6 100644 --- a/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyEffect.swift +++ b/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyEffect.swift @@ -5,6 +5,9 @@ import BitwardenSdk /// Effects that can be processed by a `UsePasskeyProcessor`. /// enum UsePasskeyEffect: Equatable { + /// The user requested deletion of a registered credential. + case deleteCredential(Fido2CredentialAutofillView) + /// The view appeared, and should load the list of registered credentials. case loadRegisteredCredentials diff --git a/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyProcessor.swift b/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyProcessor.swift index 076b45f93d..af8362dfca 100644 --- a/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyProcessor.swift +++ b/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyProcessor.swift @@ -41,6 +41,8 @@ final class UsePasskeyProcessor: StateProcessor< override func perform(_ effect: UsePasskeyEffect) async { switch effect { + case let .deleteCredential(credential): + await deleteCredential(credential) case .loadRegisteredCredentials: await loadRegisteredCredentials() case let .selectCredential(credential): @@ -65,6 +67,16 @@ final class UsePasskeyProcessor: StateProcessor< } } + /// Deletes the given credential, then reloads the registered credentials list. + private func deleteCredential(_ credential: Fido2CredentialAutofillView) async { + do { + try await passkeyService.deleteCredential(cipherId: credential.cipherId) + await loadRegisteredCredentials() + } catch { + state.status = .failure(error.localizedDescription) + } + } + /// Loads the list of credentials registered so far, across app launches. private func loadRegisteredCredentials() async { defer { state.isLoadingCredentials = false } diff --git a/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyProcessorTests.swift b/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyProcessorTests.swift index 2a0544f9a7..787427c81a 100644 --- a/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyProcessorTests.swift +++ b/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyProcessorTests.swift @@ -45,6 +45,32 @@ class UsePasskeyProcessorTests: BitwardenTestCase { XCTAssertTrue(subject.state.isLoadingCredentials) } + /// `perform(.deleteCredential)` deletes the credential's cipher and reloads the registered + /// credentials list. + @MainActor + func test_perform_deleteCredential_success() async { + let credential = Fido2CredentialAutofillView.fixture(cipherId: "cipher-1") + subject.state.registeredCredentials = [credential] + passkeyService.registeredCredentialsReturnValue = [] + + await subject.perform(.deleteCredential(credential)) + + XCTAssertEqual(passkeyService.deleteCredentialReceivedCipherId, "cipher-1") + XCTAssertTrue(passkeyService.registeredCredentialsCalled) + XCTAssertEqual(subject.state.registeredCredentials, []) + } + + /// `perform(.deleteCredential)` sets status to `.failure` when deletion throws. + @MainActor + func test_perform_deleteCredential_failure() async { + let credential = Fido2CredentialAutofillView.fixture(cipherId: "cipher-1") + passkeyService.deleteCredentialThrowableError = BitwardenTestError.example + + await subject.perform(.deleteCredential(credential)) + + XCTAssertEqual(subject.state.status, .failure(BitwardenTestError.example.localizedDescription)) + } + /// `perform(.loadRegisteredCredentials)` populates the registered credentials list and clears /// the loading flag. @MainActor diff --git a/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyView.swift b/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyView.swift index b973719ee5..c0daa19335 100644 --- a/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyView.swift +++ b/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyView.swift @@ -48,22 +48,48 @@ struct UsePasskeyView: View { Button { Task { await store.perform(.selectCredential(credential)) } } label: { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 2) { Text(credential.rpId) if let userName = credential.userNameForUi { Text(userName) .font(.footnote) .foregroundStyle(.secondary) } - Text(credential.credentialId.prefix(4).asHexString()) + Text( + Localizations.xColonY( + Localizations.credentialId, + credential.credentialId.asHexString(), + ), + ) + .font(.caption2) + .foregroundStyle(.secondary) + Text(Localizations.xColonY(Localizations.cipherId, credential.cipherId)) .font(.caption2) .foregroundStyle(.secondary) + Text(Localizations.xColonY(Localizations.userHandle, credential.userHandle.asHexString())) + .font(.caption2) + .foregroundStyle(.secondary) + if credential.hasCounter { + Text(Localizations.usesSignatureCounter) + .font(.caption2) + .foregroundStyle(.secondary) + } } } .accessibilityIdentifier( "RegisteredCredentialRow_\(credential.rpId)_\(credential.credentialId.asHexString())", ) .disabled(store.state.status == .inProgress) + .swipeActions { + Button(role: .destructive) { + Task { await store.perform(.deleteCredential(credential)) } + } label: { + Label(Localizations.delete, systemImage: "trash") + } + .accessibilityIdentifier( + "DeleteCredentialButton_\(credential.rpId)_\(credential.credentialId.asHexString())", + ) + } } } } header: { diff --git a/TestHarnessShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings b/TestHarnessShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings index 64cd1280b4..79cb4d414a 100644 --- a/TestHarnessShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings +++ b/TestHarnessShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings @@ -7,6 +7,7 @@ "CardDetails" = "Card Details"; "CardholderName" = "Cardholder Name"; "CardNumber" = "Card Number"; +"CipherId" = "Cipher ID"; "ConfirmPassword" = "Confirm Password"; "CreateAccount" = "Create Account"; "CreateAccountForm" = "Create Account Form"; @@ -14,6 +15,7 @@ "CreatePasskey" = "Create Passkey"; "CredentialId" = "Credential ID"; "Credentials" = "Credentials"; +"Delete" = "Delete"; "DisplayName" = "Display Name"; "EnterCardDetailsAbove" = "Enter card details above"; "EnterCredentialsAbove" = "Enter credentials above"; @@ -31,7 +33,7 @@ "RegisterPasskey" = "Register Passkey"; "RegisterPasskeyFormDescriptionLong" = "Fill in the fields above, then tap Register Passkey. The credential is created directly through the Bitwarden SDK, with no OS passkey sheet or separate Bitwarden app involved."; "RegisteredCredentials" = "Registered Credentials"; -"RegisteredCredentialsFooterDescriptionLong" = "Tap a credential to sign in as that specific passkey through the Bitwarden SDK."; +"RegisteredCredentialsFooterDescriptionLong" = "Tap a credential to sign in as that specific passkey through the Bitwarden SDK. Swipe to delete a passkey."; "RegistrationResult" = "Registration Result"; "RelyingPartyId" = "Relying Party ID"; "Result" = "Result"; @@ -48,9 +50,11 @@ "TOTPAutofillForm" = "TOTP Autofill Form"; "TapTheTOTPCodeFieldAndSelectDescriptionLong" = "Tap the TOTP Code field and select a code from Bitwarden. Requires Bitwarden PM as AutoFill provider and a saved Login with a TOTP seed."; "TOTPCode" = "TOTP Code"; +"UserHandle" = "User Handle"; "Username" = "Username"; "UsePasskey" = "Use Passkey"; "UseThisLoginFormToTestAutofillFunctionality" = "Use this login form to test autofill functionality."; +"UsesSignatureCounter" = "Uses signature counter"; "XColonY" = "%@: %@"; "DateFieldPicker" = "Date Field Picker"; "DateFieldPickerDescription" = "Tap the field to expand the inline calendar and select a date.";