wordpress-mobile / wordpress-mobile/WordPress-iOS
Encode the keychain "not found" contract in `KeychainAccessible` (test doubles have drifted)
Nobody has claimed this yet.
- Dominant language
- Swift
- Stars
- 3.9k
- Forks
- 1.2k
- Avg merge
- 23h 51m
- Merged PRs (30d)
- 58
Description
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
.notFoundfor 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 theSFHFKeychainUtilsNSError), andisRealKeychainFailuregoes 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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reading KeychainAccessible and the real implementations AppKeychain, SharedKeychain, and KeychainUtils, then inspect the four named test doubles and SharedDataIssueSolver.migrateAuthKey. Done means the chosen not-found contract is enforced across conformers, the doubles share faithful username/serviceName behavior, migration coverage can exercise the write path, and isRealKeychainFailure is removed.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- ios, swift
- Domain
- authentication, mobile, security
- Issue type
- Refactor
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100