Skip to content
Merged
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
Expand Up @@ -11,8 +11,13 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
shouldUseLaunchSchemeArgsEnv = "YES">
<TestPlans>
<TestPlanReference
reference = "container:TestPlans/Individual/VaultiOSAutofillTests.xctestplan"
default = "YES">
</TestPlanReference>
</TestPlans>
<Testables>
<TestableReference
skipped = "NO">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import Foundation
import VaultCore
import VaultFeed

/// Resolves a QuickType-bar OTP credential request without user
/// interaction. Extracted from `VaultCredentialProviderViewController` so
/// the security-relevant gating — auth-required items and HOTP counters
/// must never be served without interaction — is unit-testable.
@MainActor
struct AutofillOTPCredentialResolver {
enum Outcome: Equatable {
/// A rendered TOTP code, safe to return without interaction.
case code(String)
/// The item is auth-gated or is an HOTP code (whose counter must
/// not increment without UI). The system shows the extension UI.
case userInteractionRequired
/// The record identifier is missing, malformed, or matches no
/// unlocked OTP item.
case notFound
/// Retrieval or code rendering failed.
case failure
}

private let retrieveItems: () async throws -> VaultRetrievalResult<VaultItem>
private let copyActionHandler: any VaultItemCopyActionHandler
private let clock: any EpochClock

init(
retrieveItems: @escaping () async throws -> VaultRetrievalResult<VaultItem>,
copyActionHandler: any VaultItemCopyActionHandler,
clock: any EpochClock,
) {
self.retrieveItems = retrieveItems
self.copyActionHandler = copyActionHandler
self.clock = clock
}

func resolve(recordIdentifier: String?) async -> Outcome {
guard let recordIdentifier, let itemUUID = UUID(uuidString: recordIdentifier) else {
return .notFound
}

do {
let result = try await retrieveItems()

guard let vaultItem = result.items.first(where: { $0.id.rawValue == itemUUID }),
let otpCode = vaultItem.item.otpCode
else {
return .notFound
}

// Check if the item requires authentication to access.
let copyAction = copyActionHandler.textToCopyForVaultItem(id: vaultItem.id)
if copyAction?.requiresAuthenticationToCopy == true {
return .userInteractionRequired
}

switch otpCode.type {
case let .totp(period):
let totpCode = TOTPAuthCode(period: period, data: otpCode.data)
let epochSeconds = UInt64(clock.currentTime)
return try .code(totpCode.renderCode(epochSeconds: epochSeconds))
case .hotp:
// HOTP codes require user interaction to increment the counter.
return .userInteractionRequired
}
} catch {
return .failure
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,52 +95,22 @@ open class VaultCredentialProviderViewController: ASCredentialProviderViewContro

@MainActor
private func provideOTPCredential(for request: any ASCredentialRequest) async {
// Extract the credential identity and record identifier
guard let identity = request.credentialIdentity as? ASOneTimeCodeCredentialIdentity,
let recordIdentifier = identity.recordIdentifier,
let itemUUID = UUID(uuidString: recordIdentifier)
else {
extensionContext.cancelRequest(withError: ASExtensionError(.credentialIdentityNotFound))
return
}

do {
// Retrieve all items from the vault to find the matching OTP item
let result = try await VaultRoot.vaultStore.retrieve(query: .init())

guard let vaultItem = result.items.first(where: { $0.id.rawValue == itemUUID }),
let otpCode = vaultItem.item.otpCode
else {
extensionContext.cancelRequest(withError: ASExtensionError(.credentialIdentityNotFound))
return
}

// Check if the item requires authentication to access
let copyAction = VaultRoot.vaultItemCopyHandler.textToCopyForVaultItem(id: vaultItem.id)
if copyAction?.requiresAuthenticationToCopy == true {
// Require user interaction for authentication
extensionContext.cancelRequest(withError: ASExtensionError(.userInteractionRequired))
return
}

// Generate the OTP code based on the type
let codeString: String
switch otpCode.type {
case let .totp(period):
let totpCode = TOTPAuthCode(period: period, data: otpCode.data)
let epochSeconds = UInt64(Date().timeIntervalSince1970)
codeString = try totpCode.renderCode(epochSeconds: epochSeconds)
case .hotp:
// HOTP codes require user interaction to increment counter
extensionContext.cancelRequest(withError: ASExtensionError(.userInteractionRequired))
return
}
let identity = request.credentialIdentity as? ASOneTimeCodeCredentialIdentity
let resolver = AutofillOTPCredentialResolver(
retrieveItems: { try await VaultRoot.vaultStore.retrieve(query: .init()) },
copyActionHandler: VaultRoot.vaultItemCopyHandler,
clock: VaultRoot.clock,
)

// Complete the request with the generated code
switch await resolver.resolve(recordIdentifier: identity?.recordIdentifier) {
case let .code(codeString):
let credential = ASOneTimeCodeCredential(code: codeString)
extensionContext.completeOneTimeCodeRequest(using: credential, completionHandler: nil)

} catch {
case .userInteractionRequired:
extensionContext.cancelRequest(withError: ASExtensionError(.userInteractionRequired))
case .notFound:
extensionContext.cancelRequest(withError: ASExtensionError(.credentialIdentityNotFound))
case .failure:
extensionContext.cancelRequest(withError: ASExtensionError(.failed))
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import Foundation
import FoundationExtensions
import Testing
import VaultCore
import VaultFeed
@testable import VaultiOSAutofill

@MainActor
struct AutofillOTPCredentialResolverTests {
@Test
func resolve_nilRecordIdentifier_returnsNotFound() async {
let sut = makeSUT()

let outcome = await sut.resolve(recordIdentifier: nil)

#expect(outcome == .notFound)
}

@Test
func resolve_malformedRecordIdentifier_returnsNotFound() async {
let sut = makeSUT()

let outcome = await sut.resolve(recordIdentifier: "not-a-uuid")

#expect(outcome == .notFound)
}

@Test
func resolve_unknownItemID_returnsNotFound() async {
let sut = makeSUT(items: [makeOTPItem(type: .totp())])

let outcome = await sut.resolve(recordIdentifier: UUID().uuidString)

#expect(outcome == .notFound)
}

@Test
func resolve_nonOTPItem_returnsNotFound() async {
let note = anyVaultItem()
let sut = makeSUT(items: [note])

let outcome = await sut.resolve(recordIdentifier: note.id.rawValue.uuidString)

#expect(outcome == .notFound)
}

@Test
func resolve_authRequiredItem_returnsUserInteractionRequired() async {
// The security gate: an item whose copy action demands device
// authentication must never be served without interaction.
let item = makeOTPItem(type: .totp())
let sut = makeSUT(items: [item], requiresAuthenticationToCopy: true)

let outcome = await sut.resolve(recordIdentifier: item.id.rawValue.uuidString)

#expect(outcome == .userInteractionRequired)
}

@Test
func resolve_hotpItem_returnsUserInteractionRequired() async {
// HOTP counters must not increment without UI.
let item = makeOTPItem(type: .hotp())
let sut = makeSUT(items: [item])

let outcome = await sut.resolve(recordIdentifier: item.id.rawValue.uuidString)

#expect(outcome == .userInteractionRequired)
}

@Test
func resolve_totpItem_returnsCodeRenderedForClockEpoch() async throws {
let code = makeTOTPCode(period: 30)
let item = VaultItem(metadata: anyVaultItemMetadata(), item: .otpCode(code))
let clock = EpochClockMock(currentTime: 1_234_567_890)
let sut = makeSUT(items: [item], clock: clock)

let outcome = await sut.resolve(recordIdentifier: item.id.rawValue.uuidString)

let expected = try TOTPAuthCode(period: 30, data: code.data)
.renderCode(epochSeconds: 1_234_567_890)
#expect(outcome == .code(expected))
}

@Test
func resolve_retrievalError_returnsFailure() async {
struct RetrievalError: Error {}
let sut = makeSUT(retrieveItems: { throw RetrievalError() })

let outcome = await sut.resolve(recordIdentifier: UUID().uuidString)

#expect(outcome == .failure)
}
}

// MARK: - Helpers

extension AutofillOTPCredentialResolverTests {
private func makeSUT(
items: [VaultItem] = [],
requiresAuthenticationToCopy: Bool = false,
clock: EpochClockMock = EpochClockMock(currentTime: 100),
) -> AutofillOTPCredentialResolver {
makeSUT(
retrieveItems: { .init(items: items) },
requiresAuthenticationToCopy: requiresAuthenticationToCopy,
clock: clock,
)
}

private func makeSUT(
retrieveItems: @escaping () async throws -> VaultRetrievalResult<VaultItem>,
requiresAuthenticationToCopy: Bool = false,
clock: EpochClockMock = EpochClockMock(currentTime: 100),
) -> AutofillOTPCredentialResolver {
AutofillOTPCredentialResolver(
retrieveItems: retrieveItems,
copyActionHandler: CopyActionHandlerStub(requiresAuthenticationToCopy: requiresAuthenticationToCopy),
clock: clock,
)
}

private func makeOTPItem(type: OTPAuthType) -> VaultItem {
VaultItem(
metadata: anyVaultItemMetadata(),
item: .otpCode(OTPAuthCode(
type: type,
data: makeOTPData(),
)),
)
}

private func makeTOTPCode(period: UInt64) -> OTPAuthCode {
OTPAuthCode(type: .totp(period: period), data: makeOTPData())
}

private func makeOTPData() -> OTPAuthCodeData {
OTPAuthCodeData(
secret: .init(data: Data.random(count: 50), format: .base32),
accountName: "Some Account",
)
}

private struct CopyActionHandlerStub: VaultItemCopyActionHandler {
let requiresAuthenticationToCopy: Bool

func textToCopyForVaultItem(id _: Identifier<VaultItem>) -> VaultTextCopyAction? {
VaultTextCopyAction(
text: "123456",
requiresAuthenticationToCopy: requiresAuthenticationToCopy,
contentType: .otp,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import Combine
import Foundation
import FoundationExtensions
import TestHelpers
import Testing
import VaultSettings
@testable import VaultiOSAutofill

@MainActor
struct VaultAutofillViewModelTests {
@Test
func init_displaysNoFeature() throws {
let sut = try makeSUT()

#expect(sut.feature == nil)
}

@Test
func show_setsDisplayedFeature() throws {
let sut = try makeSUT()

sut.show(feature: .showAllCodesSelector)

#expect(sut.feature == .showAllCodesSelector)
}

@Test
func dismissConfiguration_publishesDismiss() throws {
let sut = try makeSUT()
var dismissCount = 0
let cancellable = sut.configurationDismissPublisher.sink { dismissCount += 1 }
defer { cancellable.cancel() }

sut.dismissConfiguration()

#expect(dismissCount == 1)
}

@Test
func textToInsertPublisher_filtersBlankStrings() throws {
let sut = try makeSUT()
var received = [String]()
let cancellable = sut.textToInsertPublisher.sink { received.append($0) }
defer { cancellable.cancel() }

sut.textToInsertSubject.send("123456")
sut.textToInsertSubject.send("")
sut.textToInsertSubject.send(" ")
sut.textToInsertSubject.send("654321")

#expect(received == ["123456", "654321"])
}

@Test
func cancelRequestPublisher_forwardsReason() throws {
let sut = try makeSUT()
var received = [VaultAutofillViewModel.RequestCancelReason]()
let cancellable = sut.cancelRequestPublisher.sink { received.append($0) }
defer { cancellable.cancel() }

sut.cancelRequestSubject.send(.userCancelled)

#expect(received == [.userCancelled])
}
}

// MARK: - Helpers

extension VaultAutofillViewModelTests {
private func makeSUT() throws -> VaultAutofillViewModel {
try VaultAutofillViewModel(
localSettings: LocalSettings(defaults: Defaults.nonPersistent()),
)
}
}