Skip to content

Encode the keychain "not found" contract in KeychainAccessible (test doubles have drifted) #25659

Description

@jkmassel

Problem

KeychainAccessible types the signature but not the contract:

func getPassword(for username: String, serviceName: String) throws -> String

A missing item can only be signaled by throwing, but nothing says which throw means "not found" vs. a real failure. The real implementations (AppKeychain, SharedKeychain, KeychainUtils) throw a bridged SFHFKeychainUtils NSError on a miss, and the not-found-vs-failure distinction is recovered after the fact by a free function — isRealKeychainFailure — that sniffs the NSError domain/code. The contract is implicit and stringly-typed.

Because nothing pins it down, the test doubles have each guessed differently:

Double not-found behavior
Tests/KeystoneTests/Tests/Jetpack/KeychainUtilsMock.swift returns "" (no throw)
Tests/KeystoneTests/Helpers/TestKeychain.swift throws TestKeychainErrors.keychainItemNotFound
Modules/Tests/WordPressDataTests/Helpers/MockKeychainService.swift throws MockKeychainError.notFound (and keys storage by username only, ignoring serviceName)
Modules/Tests/WordPressSharedTests/KeychainStub.swift throws StubError.notFound

Three throw (three different error types); one returns an empty string; they also disagree on the storage/key model.

Why it bites

SharedDataIssueSolver.migrateAuthKey has no test coverage, and the ""-returning KeychainUtilsMock (injected by the migrator tests) makes it hard to add: the "already migrated" guard if let _ = try? appKeychain.getPassword(…, .jetpack) always succeeds against that mock, so the migration write path can never be exercised. More broadly, a test written against the wrong double can pass while the real keychain behaves differently on a miss.

Proposed fix

Move the contract into the type:

enum KeychainError: Error { case notFound; case failure(OSStatus) }
func getPassword(for username: String, serviceName: String) throws(KeychainError) -> String
  • Every conformer — real and test — must produce .notFound for a miss; the compiler enforces it, and "" is no longer a way to signal absence.
  • The classification happens once, at the boundary (in AppKeychain/SharedKeychain, mapping the SFHFKeychainUtils NSError), and isRealKeychainFailure goes away — it only exists because the current contract can't express the distinction.
  • Then consolidate the four doubles into a single faithful in-memory implementation.

A lighter version is throws -> String? (nil = not found), which beats today's contract but doesn't carry the failure distinction.

Provenance

Pre-existing. The protocol and the "" mock are both on trunk (the mock since at least #24462, Apr 2025; trunk's migrateAuthKey has the identical untested try? pattern). Surfaced while reviewing #25654 (the keychain access-group split); #25658 is a companion fix for an unrelated logout bug. This is not a blocker for either — filing separately so the redesign isn't loaded onto the access-group work.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions