Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import BitwardenSdk
import Foundation

extension GetAssertionResult {
static func fixture(
credentialId: Data = Data([0x03]),
selectedCredential: SelectedCredential = SelectedCredential(cipher: .fixture(), credential: .fixture()),
) -> GetAssertionResult {
GetAssertionResult(
credentialId: credentialId,
authenticatorData: Data([0x02]),
signature: Data([0x04]),
userHandle: Data([0x05]),
selectedCredential: selectedCredential,
extensions: GetAssertionExtensionsOutput(prf: nil),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// MARK: - UsePasskeyAction

/// Actions that can be processed by a `UsePasskeyProcessor`. This screen has no synchronous
/// user interactions, so this has no cases.
///
enum UsePasskeyAction: Equatable {}
13 changes: 13 additions & 0 deletions TestHarnessShared/UI/Autofill/Passkey/UsePasskeyEffect.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import BitwardenSdk

// MARK: - UsePasskeyEffect

/// Effects that can be processed by a `UsePasskeyProcessor`.
///
enum UsePasskeyEffect: Equatable {
/// The view appeared, and should load the list of registered credentials.
case loadRegisteredCredentials

/// The user selected a credential from the registered credentials list.
case selectCredential(Fido2CredentialAutofillView)
}
77 changes: 77 additions & 0 deletions TestHarnessShared/UI/Autofill/Passkey/UsePasskeyProcessor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import BitwardenKit
import BitwardenSdk
import Foundation

// MARK: - UsePasskeyProcessor

/// The processor for the use passkey test screen.
///
final class UsePasskeyProcessor: StateProcessor<
UsePasskeyState,
UsePasskeyAction,
UsePasskeyEffect,
> {
// MARK: Private Properties

/// The coordinator that handles navigation.
private let coordinator: AnyCoordinator<RootRoute, Void>

/// The service used to perform passkey assertion through the Bitwarden SDK.
private let passkeyService: PasskeyService

// MARK: Initialization

/// Initializes a `UsePasskeyProcessor`.
///
/// - Parameters:
/// - coordinator: The coordinator that handles navigation.
/// - passkeyService: The service used to perform passkey assertion through the
/// Bitwarden SDK.
///
init(
coordinator: AnyCoordinator<RootRoute, Void>,
passkeyService: PasskeyService,
) {
self.coordinator = coordinator
self.passkeyService = passkeyService
super.init(state: UsePasskeyState())
}

// MARK: Methods

override func perform(_ effect: UsePasskeyEffect) async {
switch effect {
case .loadRegisteredCredentials:
await loadRegisteredCredentials()
case let .selectCredential(credential):
await assertPasskey(credentialId: credential.credentialId, rpId: credential.rpId)
}
}

// MARK: Private

/// Orchestrates state transitions and calls `passkeyService.assertPasskey`.
private func assertPasskey(credentialId: Data?, rpId: String) async {
state.status = .inProgress
do {
let result = try await passkeyService.assertPasskey(credentialId: credentialId, rpId: rpId)
state.status = .success(
credentialId: result.credentialId.base64EncodedString(),
rpId: rpId,
userName: result.selectedCredential.credential.userName,
)
} 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 }
do {
state.registeredCredentials = try await passkeyService.registeredCredentials()
} catch {
state.status = .failure(error.localizedDescription)
}
}
}
117 changes: 117 additions & 0 deletions TestHarnessShared/UI/Autofill/Passkey/UsePasskeyProcessorTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import BitwardenKit
import BitwardenKitMocks
import BitwardenSdk
import TestHelpers
import XCTest

@testable import TestHarnessShared

// MARK: - UsePasskeyProcessorTests

/// Tests for `UsePasskeyProcessor`.
///
class UsePasskeyProcessorTests: BitwardenTestCase {
// MARK: Properties

var coordinator: MockCoordinator<RootRoute, Void>!
var passkeyService: MockPasskeyService!
var subject: UsePasskeyProcessor!

// MARK: Setup & Teardown

@MainActor
override func setUp() {
super.setUp()
coordinator = MockCoordinator()
passkeyService = MockPasskeyService()
subject = UsePasskeyProcessor(
coordinator: coordinator.asAnyCoordinator(),
passkeyService: passkeyService,
)
}

override func tearDown() {
super.tearDown()
coordinator = nil
passkeyService = nil
subject = nil
}

// MARK: Effect Tests

/// The initial state marks credentials as still loading.
@MainActor
func test_state_isLoadingCredentials_initiallyTrue() {
XCTAssertTrue(subject.state.isLoadingCredentials)
}

/// `perform(.loadRegisteredCredentials)` populates the registered credentials list and clears
/// the loading flag.
@MainActor
func test_perform_loadRegisteredCredentials_success() async {
passkeyService.registeredCredentialsReturnValue = [.fixture(rpId: "bitwarden.com")]

await subject.perform(.loadRegisteredCredentials)

XCTAssertEqual(subject.state.registeredCredentials, [.fixture(rpId: "bitwarden.com")])
XCTAssertFalse(subject.state.isLoadingCredentials)
}

/// `perform(.loadRegisteredCredentials)` sets status to `.failure` when loading throws, and
/// clears the loading flag.
@MainActor
func test_perform_loadRegisteredCredentials_failure() async {
passkeyService.registeredCredentialsThrowableError = BitwardenTestError.example

await subject.perform(.loadRegisteredCredentials)

XCTAssertEqual(subject.state.status, .failure(BitwardenTestError.example.localizedDescription))
XCTAssertFalse(subject.state.isLoadingCredentials)
}

/// `perform(.selectCredential)` asserts using the selected credential's specific credential
/// ID and RP ID.
@MainActor
func test_perform_selectCredential_passesCredentialIdAndRpId() async {
let credential = Fido2CredentialAutofillView.fixture(credentialId: Data([0x09]), rpId: "example.com")
passkeyService.assertPasskeyReturnValue = .fixture()

await subject.perform(.selectCredential(credential))

XCTAssertEqual(passkeyService.assertPasskeyReceivedArguments?.credentialId, Data([0x09]))
XCTAssertEqual(passkeyService.assertPasskeyReceivedArguments?.rpId, "example.com")
}

/// `perform(.selectCredential)` sets status to `.success` with the matched credential's RP ID
/// and username when assertion succeeds.
@MainActor
func test_perform_selectCredential_success() async {
let credential = Fido2CredentialAutofillView.fixture(credentialId: Data([0x09]), rpId: "example.com")
passkeyService.assertPasskeyReturnValue = .fixture(
credentialId: Data([0x01, 0x02, 0x03]),
selectedCredential: SelectedCredential(cipher: .fixture(), credential: .fixture(userName: "alice")),
)

await subject.perform(.selectCredential(credential))

XCTAssertEqual(
subject.state.status,
.success(
credentialId: Data([0x01, 0x02, 0x03]).base64EncodedString(),
rpId: "example.com",
userName: "alice",
),
)
}

/// `perform(.selectCredential)` sets status to `.failure` when assertion throws.
@MainActor
func test_perform_selectCredential_failure() async {
let credential = Fido2CredentialAutofillView.fixture(credentialId: Data([0x09]), rpId: "example.com")
passkeyService.assertPasskeyThrowableError = BitwardenTestError.example

await subject.perform(.selectCredential(credential))

XCTAssertEqual(subject.state.status, .failure(BitwardenTestError.example.localizedDescription))
}
}
39 changes: 39 additions & 0 deletions TestHarnessShared/UI/Autofill/Passkey/UsePasskeyState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import BitwardenSdk
import Foundation

// MARK: - UsePasskeyState

/// The state for the use passkey test screen.
///
struct UsePasskeyState: Equatable {
// MARK: Types

/// The current status of a passkey assertion attempt.
enum AssertionStatus: Equatable {
/// Assertion failed with the associated error description.
case failure(String)

/// No assertion attempt has been made.
case idle

/// An assertion request is in progress.
case inProgress

/// Assertion completed successfully for the associated credential.
case success(credentialId: String, rpId: String, userName: String?)
}

// MARK: Properties

/// Whether the registered credentials are still being loaded.
var isLoadingCredentials = true

/// The credentials registered so far, across app launches.
var registeredCredentials: [Fido2CredentialAutofillView] = []

/// The current assertion status.
var status: AssertionStatus = .idle

/// The title of the screen.
var title: String = Localizations.usePasskey
}
Loading
Loading