add detailed API tests checking how calls to PGPAgent propagate to the underlying interface

this is refactoring support, so that we can notice changes in how the
underlying APIs are called, and make changes intentionally when needed,
instead of accidentally.
This commit is contained in:
Lysann Tranvouez 2026-03-11 11:36:36 +01:00
parent 76db529764
commit d136175d93
4 changed files with 760 additions and 0 deletions

View file

@ -116,6 +116,8 @@
5F9D7B0F27AF6FD200A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
8A4716692F5EF56900C7A64D /* AppKeychainTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */; };
8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */; };
8AB3AD8C2F615FA50081DE16 /* MockPGPInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AB3AD8A2F615FA50081DE16 /* MockPGPInterface.swift */; };
8AB3AD8D2F615FA50081DE16 /* PGPAgentLowLevelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AB3AD8B2F615FA50081DE16 /* PGPAgentLowLevelTests.swift */; };
8AD8EBF32F5E2723007475AB /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 8AD8EBF22F5E268D007475AB /* Fixtures */; };
9A1D1CE526E5D1CE0052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE426E5D1CE0052028E /* OneTimePassword */; };
9A1D1CE726E5D2230052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE626E5D2230052028E /* OneTimePassword */; };
@ -427,6 +429,8 @@
5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CryptoTokenKit.framework; path = System/Library/Frameworks/CryptoTokenKit.framework; sourceTree = SDKROOT; };
8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKeychainTest.swift; sourceTree = "<group>"; };
8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerTest.swift; sourceTree = "<group>"; };
8AB3AD8A2F615FA50081DE16 /* MockPGPInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPGPInterface.swift; sourceTree = "<group>"; };
8AB3AD8B2F615FA50081DE16 /* PGPAgentLowLevelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PGPAgentLowLevelTests.swift; sourceTree = "<group>"; };
8AD8EBF22F5E268D007475AB /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = "<group>"; };
9A1EF0B324C50DD80074FEAC /* passBeta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBeta.entitlements; sourceTree = "<group>"; };
9A1EF0B424C50E780074FEAC /* passBetaAutoFillExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaAutoFillExtension.entitlements; sourceTree = "<group>"; };
@ -776,6 +780,22 @@
path = Controllers;
sourceTree = "<group>";
};
8AB3AD8E2F615FD70081DE16 /* Mocks */ = {
isa = PBXGroup;
children = (
8AB3AD8A2F615FA50081DE16 /* MockPGPInterface.swift */,
);
path = Mocks;
sourceTree = "<group>";
};
8AB3AD8F2F61600B0081DE16 /* LowLevel */ = {
isa = PBXGroup;
children = (
8AB3AD8B2F615FA50081DE16 /* PGPAgentLowLevelTests.swift */,
);
path = LowLevel;
sourceTree = "<group>";
};
9A58664F25AADB66006719C2 /* Services */ = {
isa = PBXGroup;
children = (
@ -901,6 +921,8 @@
30697C5521F63F870064FCAC /* Extensions */,
8AD8EBF22F5E268D007475AB /* Fixtures */,
301F6464216164670071A4CE /* Helpers */,
8AB3AD8F2F61600B0081DE16 /* LowLevel */,
8AB3AD8E2F615FD70081DE16 /* Mocks */,
30C015A7214ED378005BB6DF /* Models */,
30C015A6214ED32A005BB6DF /* Parser */,
30B4C7BB24085A3C008B86F7 /* Passwords */,
@ -1644,6 +1666,8 @@
8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */,
301F646D216166AA0071A4CE /* AdditionFieldTest.swift in Sources */,
9ADC954124418A5F0005402E /* PasswordStoreTest.swift in Sources */,
8AB3AD8C2F615FA50081DE16 /* MockPGPInterface.swift in Sources */,
8AB3AD8D2F615FA50081DE16 /* PGPAgentLowLevelTests.swift in Sources */,
30BAC8CB22E3BB6C00438475 /* DictBasedKeychain.swift in Sources */,
DC6474612D46A8F8004B4BBC /* GitRepositoryTest.swift in Sources */,
A2699ACF24027D9500F36323 /* PasswordTableEntryTest.swift in Sources */,

View file

@ -17,6 +17,11 @@ public class PGPAgent {
self.keyStore = keyStore
}
init(keyStore: KeyStore, pgpInterface: PGPInterface) {
self.keyStore = keyStore
self.pgpInterface = pgpInterface
}
public func initKeys() throws {
guard let publicKey: String = keyStore.get(for: PGPKey.PUBLIC.getKeychainKey()),
let privateKey: String = keyStore.get(for: PGPKey.PRIVATE.getKeychainKey()) else {

View file

@ -0,0 +1,661 @@
//
// PGPAgentLowLevelTests.swift
// passKitTests
//
// Detailed unit tests tracking the exact API call behavior of PGPAgent.decrypt.
// Uses MockPGPInterface to verify what arguments are passed to the underlying
// PGPInterface methods, and how passphrase resolution interacts with the keystore
// and the requestPGPKeyPassphrase callback.
//
import XCTest
@testable import passKit
final class PGPAgentLowLevelTests: XCTestCase {
private var keychain: DictBasedKeychain!
private var mockPGP: MockPGPInterface!
private var agent: PGPAgent!
private let testEncryptedData = Data("encrypted-payload".utf8)
private let testDecryptedData = Data("decrypted-payload".utf8)
/// Tracks all calls to requestPGPKeyPassphrase closures created via `passphraseCallback(_:)`.
private var passphraseRequests: [String] = []
/// Creates a requestPGPKeyPassphrase closure that records the keyID it's called with
/// into `passphraseRequests` and returns `response`.
private func passphraseCallback(_ response: String) -> (String) -> String {
{ [self] keyID in
passphraseRequests.append(keyID)
return response
}
}
override func setUp() {
super.setUp()
keychain = DictBasedKeychain()
// Set pgpKeyPassphrase key so checkAndInit() doesn't re-init and overwrite our mock.
keychain.add(string: "dummy", for: Globals.pgpKeyPassphrase)
mockPGP = MockPGPInterface()
// some defaults
mockPGP.decryptResult = testDecryptedData
mockPGP.encryptResult = Data("mock-encrypted".utf8)
passphraseRequests = []
agent = PGPAgent(keyStore: keychain, pgpInterface: mockPGP)
}
override func tearDown() {
keychain.removeAllContent()
super.tearDown()
}
// MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - Key Resolution
/// When the private key is found, decrypt is called with the provided keyID.
func testDecryptWithKeyID_keyFound_usesProvidedKeyID() throws {
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
let result = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("pass"))
XCTAssertEqual(result, testDecryptedData)
XCTAssertEqual(mockPGP.decryptCalls.count, 1)
XCTAssertEqual(mockPGP.decryptCalls[0].keyID, longFingerprint)
XCTAssertEqual(mockPGP.decryptCalls[0].encryptedData, testEncryptedData)
XCTAssertEqual(passphraseRequests, [longFingerprint])
}
/// When the private key is NOT found but there's exactly one key, falls back to that key.
func testDecryptWithKeyID_keyNotFound_singleKey_fallsBackToOnlyKey() throws {
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [] // requested key not found
mockPGP.keyIDs = [longFingerprint]
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: "UNKNOWN", requestPGPKeyPassphrase: passphraseCallback("pass"))
XCTAssertEqual(mockPGP.decryptCalls.count, 1)
// The keyID passed to pgpInterface.decrypt should be the fallback key, not the requested one.
XCTAssertEqual(mockPGP.decryptCalls[0].keyID, longFingerprint)
XCTAssertEqual(passphraseRequests, [longFingerprint])
}
/// When the private key is NOT found and there are multiple keys, throws pgpPrivateKeyNotFound.
func testDecryptWithKeyID_keyNotFound_multipleKeys_throws() {
mockPGP.privateKeyIDs = []
mockPGP.keyIDs = ["4712286271220db299883ea7062e678da1024dae", "787eae1a5fa3e749aa34cc6aa0645ebed862027e"]
XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: "UNKNOWN", requestPGPKeyPassphrase: passphraseCallback("pass"))) { error in
XCTAssertEqual(error as? AppError, AppError.pgpPrivateKeyNotFound(keyID: "UNKNOWN"))
}
// pgpInterface.decrypt should NOT have been called
XCTAssertEqual(mockPGP.decryptCalls.count, 0)
XCTAssertEqual(passphraseRequests, [], "requestPGPKeyPassphrase should not be called when key resolution fails")
}
/// containsPrivateKey is called with the provided keyID to check membership.
func testDecryptWithKeyID_checksContainsPrivateKey() throws {
let shortID = "a1024dae"
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass"))
XCTAssertEqual(mockPGP.containsPrivateKeyCalls, [shortID])
XCTAssertEqual(passphraseRequests, [shortID])
}
// MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - Passphrase Resolution
/// On first decrypt (latestDecryptStatus=true), the passphrase is looked up from keystore first.
/// If found in keystore, requestPGPKeyPassphrase is NOT called.
func testDecryptWithKeyID_firstCall_passphraseFromKeystore() throws {
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
keychain.add(string: "stored-passphrase", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("requested-passphrase"))
XCTAssertEqual(passphraseRequests, [], "requestPGPKeyPassphrase should not be called when passphrase is in keystore")
XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "stored-passphrase")
}
/// On first decrypt, if keystore doesn't have the passphrase, requestPGPKeyPassphrase is called.
/// The keyID passed to requestPGPKeyPassphrase is the (possibly resolved) keyID.
func testDecryptWithKeyID_firstCall_passphraseFromRequest() throws {
let shortID = "a1024dae"
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
// No passphrase in keystore for this key.
XCTAssertFalse(keychain.contains(key: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID)))
XCTAssertFalse(keychain.contains(key: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint)))
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("my-passphrase"))
XCTAssertEqual(passphraseRequests, [shortID])
XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "my-passphrase")
}
/// On first decrypt with key fallback, the resolved keyID is used for passphrase lookup and request.
func testDecryptWithKeyID_firstCall_keyFallback_usesResolvedKeyIDForPassphrase() throws {
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = []
mockPGP.keyIDs = [longFingerprint]
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: "UNKNOWN", requestPGPKeyPassphrase: passphraseCallback("pass"))
// The passphrase and decrypt calls should use the resolved key, not the originally requested one.
XCTAssertEqual(passphraseRequests, [longFingerprint])
XCTAssertEqual(mockPGP.decryptCalls[0].keyID, longFingerprint)
}
/// After a failed decrypt (latestDecryptStatus=false), requestPGPKeyPassphrase is ALWAYS called,
/// even if the keystore has a cached passphrase.
func testDecryptWithKeyID_afterFailure_alwaysRequestsPassphrase() throws {
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
keychain.add(string: "stored-passphrase", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
// First call: force a failure by making decrypt throw.
mockPGP.decryptError = AppError.wrongPassphrase
XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("bad")))
// Now latestDecryptStatus=false. Second call should always request.
mockPGP.decryptError = nil
mockPGP.decryptCalls.removeAll()
passphraseRequests.removeAll()
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("fresh-passphrase"))
XCTAssertEqual(passphraseRequests, [longFingerprint], "After failure, passphrase should always be requested")
XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "fresh-passphrase")
}
/// After a successful decrypt, the next call uses keystore first (latestDecryptStatus=true).
func testDecryptWithKeyID_afterSuccess_usesKeystoreFirst() throws {
let shortID = "a1024dae"
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
// First call succeeds.
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass1"))
// Store a passphrase in keystore under the short ID (matching what PGPAgent used for lookup).
keychain.add(string: "pass1", for: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID))
mockPGP.decryptCalls.removeAll()
passphraseRequests.removeAll()
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("ignored-passphrase"))
XCTAssertEqual(passphraseRequests, [])
XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "pass1")
}
/// The passphrase keystore key is constructed with the resolved keyID (after fallback).
func testDecryptWithKeyID_passphraseKeystoreKey_usesResolvedKeyID() throws {
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = []
mockPGP.keyIDs = [longFingerprint]
keychain.add(string: "fallback-pass", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: "e9444483", requestPGPKeyPassphrase: passphraseCallback("should-not-use"))
XCTAssertEqual(passphraseRequests, [])
XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "fallback-pass")
}
// MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - Return Values & Error Propagation
/// When pgpInterface.decrypt returns nil, agent.decrypt returns nil.
func testDecryptWithKeyID_interfaceReturnsNil_returnsNil() throws {
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
mockPGP.decryptResult = nil
let result = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("pass"))
XCTAssertNil(result)
XCTAssertEqual(passphraseRequests, [longFingerprint])
}
/// When pgpInterface.decrypt returns nil, latestDecryptStatus stays false
/// (next call will always request passphrase).
func testDecryptWithKeyID_interfaceReturnsNil_statusStaysFalse() throws {
let shortID = "d862027e"
let longFingerprint = "787eae1a5fa3e749aa34cc6aa0645ebed862027e"
mockPGP.privateKeyIDs = [longFingerprint]
mockPGP.decryptResult = nil
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass"))
// Second call - should always request (latestDecryptStatus=false because nil return doesn't set it to true).
keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID))
keychain.add(string: "cached-long", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
mockPGP.decryptResult = testDecryptedData
mockPGP.decryptCalls.removeAll()
passphraseRequests.removeAll()
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("fresh"))
XCTAssertEqual(passphraseRequests, [shortID], "After nil return, passphrase should always be requested")
XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "fresh")
}
/// When pgpInterface.decrypt throws, the error propagates and latestDecryptStatus stays false.
func testDecryptWithKeyID_interfaceThrows_propagatesError() throws {
let shortID = "a1024dae"
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
mockPGP.decryptError = AppError.wrongPassphrase
XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass"))) { error in
XCTAssertEqual(error as? AppError, AppError.wrongPassphrase)
}
XCTAssertEqual(passphraseRequests, [shortID])
// Verify latestDecryptStatus stayed false: next call should always request passphrase,
// even though the keystore has one cached.
keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID))
keychain.add(string: "cached-long", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
mockPGP.decryptError = nil
mockPGP.decryptCalls.removeAll()
passphraseRequests.removeAll()
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("fresh"))
XCTAssertEqual(passphraseRequests, [shortID], "After throw, passphrase should always be requested (latestDecryptStatus=false)")
XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "fresh")
}
/// After successful decrypt, latestDecryptStatus is true.
func testDecryptWithKeyID_success_setsStatusTrue() throws {
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
// Force latestDecryptStatus to false first.
mockPGP.decryptError = AppError.wrongPassphrase
_ = try? agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("bad"))
mockPGP.decryptError = nil
mockPGP.decryptCalls.removeAll()
passphraseRequests.removeAll()
// Now succeed.
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("good"))
// Third call: latestDecryptStatus=true, so should try keystore first.
keychain.add(string: "good", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
mockPGP.decryptCalls.removeAll()
passphraseRequests.removeAll()
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("should-not-use"))
XCTAssertEqual(passphraseRequests, [], "After success, should try keystore first")
XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "good")
}
// MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - checkAndInit behavior
/// checkAndInit re-initializes if pgpKeyPassphrase is missing from keystore.
/// Since we're using a mock as pgpInterface, initKeys would overwrite it; verify the precondition holds.
func testDecryptWithKeyID_checkAndInit_requiresPGPKeyPassphraseInKeystore() throws {
// Remove the pgpKeyPassphrase sentinel, which will trigger checkAndInit -> initKeys.
keychain.removeContent(for: Globals.pgpKeyPassphrase)
// initKeys needs real PGP keys, which we don't have. It should throw keyImport.
XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: "a1024dae", requestPGPKeyPassphrase: passphraseCallback("pass"))) { error in
XCTAssertEqual(error as? AppError, AppError.keyImport)
}
XCTAssertEqual(passphraseRequests, [], "requestPGPKeyPassphrase should not be called when checkAndInit fails")
}
// MARK: - decrypt(encryptedData:requestPGPKeyPassphrase:) - No KeyID Overload
/// The no-keyID overload passes nil as keyID to pgpInterface.decrypt
func testDecryptNoKeyID_passesNilKeyIDToInterface() throws {
let result = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("pass"))
XCTAssertEqual(result, testDecryptedData)
XCTAssertEqual(mockPGP.decryptCalls.count, 1)
XCTAssertNil(mockPGP.decryptCalls[0].keyID)
}
/// The no-keyID overload requests passphrase with empty string keyID.
func testDecryptNoKeyID_requestsPassphraseWithEmptyString() throws {
_ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("pass"))
XCTAssertEqual(passphraseRequests, [""])
}
/// The no-keyID overload uses empty string as the keyID for passphrase lookup in keyStore/AppKeychain.
func testDecryptNoKeyID_usesEmptyStringForPassphraseLookup() throws {
keychain.add(string: "empty-key-pass", for: AppKeychain.getPGPKeyPassphraseKey(keyID: ""))
_ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("should-not-use"))
XCTAssertEqual(passphraseRequests, [])
XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "empty-key-pass")
}
/// After failure, the no-keyID overload always requests passphrase (with empty string).
func testDecryptNoKeyID_afterFailure_alwaysRequestsPassphrase() throws {
// Force a failure.
mockPGP.decryptError = AppError.wrongPassphrase
_ = try? agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("bad"))
// Next one can succeed
keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: ""))
mockPGP.decryptError = nil
mockPGP.decryptCalls.removeAll()
passphraseRequests.removeAll()
_ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("fresh"))
XCTAssertEqual(passphraseRequests, [""])
XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "fresh")
}
/// The no-keyID overload returns nil when interface returns nil.
func testDecryptNoKeyID_interfaceReturnsNil_returnsNil() throws {
mockPGP.decryptResult = nil
let result = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("pass"))
XCTAssertNil(result)
XCTAssertEqual(passphraseRequests, [""])
XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "pass")
}
/// The no-keyID overload propagates errors from the interface.
func testDecryptNoKeyID_interfaceThrows_propagatesError() {
mockPGP.decryptError = AppError.wrongPassphrase
XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("pass"))) { error in
XCTAssertEqual(error as? AppError, AppError.wrongPassphrase)
}
XCTAssertEqual(passphraseRequests, [""])
}
/// The no-keyID overload sets latestDecryptStatus to true on success,
/// which affects subsequent passphrase lookups.
func testDecryptNoKeyID_success_setsStatusTrue() throws {
// Force failure first.
mockPGP.decryptError = AppError.wrongPassphrase
_ = try? agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("bad"))
mockPGP.decryptError = nil
mockPGP.decryptCalls.removeAll()
passphraseRequests.removeAll()
// Succeed.
_ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("good"))
// Third call: should try keystore.
keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: ""))
mockPGP.decryptCalls.removeAll()
passphraseRequests.removeAll()
_ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("nope"))
XCTAssertEqual(passphraseRequests, [])
XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "cached")
}
/// The no-keyID overload doesn't check containsPrivateKey and doesn't resolve key.
func testDecryptNoKeyID_doesNotCheckPrivateKey() throws {
_ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("pass"))
XCTAssertEqual(mockPGP.containsPrivateKeyCalls.count, 0)
XCTAssertEqual(passphraseRequests, [""])
}
// MARK: - Key resolution error vs decrypt status ordering
/// When pgpPrivateKeyNotFound is thrown (key not found, multiple keys),
/// latestDecryptStatus is NOT changed because the error occurs BEFORE the status update.
func testDecryptWithKeyID_keyNotFound_multipleKeys_doesNotChangeDecryptStatus() throws {
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = []
mockPGP.keyIDs = [longFingerprint, "787eae1a5fa3e749aa34cc6aa0645ebed862027e"]
// This throws pgpPrivateKeyNotFound without changing latestDecryptStatus.
XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: "UNKNOWN", requestPGPKeyPassphrase: passphraseCallback("pass")))
// latestDecryptStatus should still be true (initial value).
// Next call should try keystore first.
mockPGP.privateKeyIDs = [longFingerprint]
keychain.add(string: "cached-pass", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
passphraseRequests.removeAll()
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("fresh"))
XCTAssertEqual(passphraseRequests, [], "After pgpPrivateKeyNotFound, latestDecryptStatus should be unchanged (still true)")
XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "cached-pass")
}
/// After failure + key fallback: passphrase is always requested using the RESOLVED (fallback) keyID.
func testDecryptWithKeyID_afterFailure_keyFallback_requestsWithResolvedKeyID() throws {
let shortID = "a1024dae"
let longFingerprint1 = "4712286271220db299883ea7062e678da1024dae"
let longFingerprint2 = "5fccb081ab8af48972999e2ae750acbfe9444483"
mockPGP.privateKeyIDs = [longFingerprint1]
// Force a failure using a short ID that suffix-matches longFingerprint1.
mockPGP.decryptError = AppError.wrongPassphrase
_ = try? agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("bad"))
// Now try with an unknown key that falls back to a different long fingerprint.
mockPGP.decryptError = nil
mockPGP.privateKeyIDs = []
mockPGP.keyIDs = [longFingerprint2]
mockPGP.decryptCalls.removeAll()
passphraseRequests.removeAll()
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: "e9444483", requestPGPKeyPassphrase: passphraseCallback("pass"))
XCTAssertEqual(passphraseRequests, [longFingerprint2])
XCTAssertEqual(mockPGP.decryptCalls[0].keyID, longFingerprint2)
}
// MARK: - Cross-overload latestDecryptStatus interaction
/// latestDecryptStatus is shared between both overloads.
/// A failure in the keyID overload affects the no-keyID overload.
func testDecryptStatusSharedBetweenOverloads_failureInKeyIDOverload() throws {
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
// Fail via keyID overload.
mockPGP.decryptError = AppError.wrongPassphrase
_ = try? agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("bad"))
// Next call via no-keyID overload should always request.
keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: ""))
mockPGP.decryptError = nil
mockPGP.decryptCalls.removeAll()
passphraseRequests.removeAll()
_ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("fresh"))
XCTAssertEqual(passphraseRequests, [""], "Failure in keyID overload should affect no-keyID overload")
}
/// A failure in the no-keyID overload affects the keyID overload.
func testDecryptStatusSharedBetweenOverloads_failureInNoKeyIDOverload() throws {
let shortID = "a1024dae"
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
// Fail via no-keyID overload.
mockPGP.decryptError = AppError.wrongPassphrase
_ = try? agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("bad"))
// Next call via keyID overload should always request.
mockPGP.decryptError = nil
mockPGP.decryptCalls.removeAll()
keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID))
passphraseRequests.removeAll()
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("fresh"))
XCTAssertEqual(passphraseRequests, [shortID], "Failure in no-keyID overload should affect keyID overload")
}
// MARK: - Short vs long key ID behavior
/// When caller passes a short ID and containsPrivateKey matches it (via suffix), the short ID
/// is used for passphrase lookup, request callback, and pgpInterface.decrypt it is NOT resolved
/// to a longer fingerprint.
func testDecryptWithKeyID_shortIDRecognized_shortIDFlowsThrough() throws {
let shortID = "a1024dae"
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
mockPGP.keyIDs = [longFingerprint]
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass"))
// The short ID passes through everywhere no resolution to the long fingerprint.
XCTAssertEqual(mockPGP.containsPrivateKeyCalls, [shortID])
XCTAssertEqual(mockPGP.decryptCalls[0].keyID, shortID)
XCTAssertEqual(passphraseRequests, [shortID])
}
/// When caller passes a short ID and containsPrivateKey does NOT match, but there's one key,
/// the long fingerprint from keyID[0] is used everywhere instead.
func testDecryptWithKeyID_shortIDNotRecognized_singleKey_resolvesToLongFingerprint() throws {
let shortID = "a1024dae"
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [] // short ID doesn't match
mockPGP.keyIDs = [longFingerprint]
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass"))
XCTAssertEqual(mockPGP.containsPrivateKeyCalls, [shortID])
XCTAssertEqual(mockPGP.decryptCalls[0].keyID, longFingerprint)
XCTAssertEqual(passphraseRequests, [longFingerprint])
}
/// Passphrase stored under long fingerprint is NOT found when the short ID is used for lookup
/// (because PGPAgent uses the caller's short ID as-is when containsPrivateKey matches).
func testDecryptWithKeyID_shortIDRecognized_passphraseStoredUnderLongID_missesKeystore() throws {
let shortID = "a1024dae"
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
mockPGP.keyIDs = [longFingerprint]
// Store passphrase under the LONG fingerprint.
keychain.add(string: "stored-under-long", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("from-request"))
// Keystore lookup uses the short ID, which doesn't match the long key falls through to request.
XCTAssertEqual(passphraseRequests, [shortID])
XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "from-request")
}
/// When short ID is NOT recognized and fallback resolves to long fingerprint,
/// passphrase stored under the long fingerprint IS found.
func testDecryptWithKeyID_shortIDNotRecognized_fallbackToLong_passphraseFoundInKeystore() throws {
let shortID = "a1024dae"
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = []
mockPGP.keyIDs = [longFingerprint]
keychain.add(string: "stored-under-long", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("should-not-use"))
// Keystore lookup uses the resolved long fingerprint finds the passphrase.
XCTAssertEqual(passphraseRequests, [])
XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "stored-under-long")
XCTAssertEqual(mockPGP.decryptCalls[0].keyID, longFingerprint)
}
/// Encrypt with short ID: when containsPublicKey matches (via suffix), the short ID is passed to interface.
func testEncryptWithKeyID_shortIDRecognized_shortIDFlowsThrough() throws {
let shortID = "a1024dae"
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.publicKeyIDs = [longFingerprint]
mockPGP.keyIDs = [longFingerprint]
_ = try agent.encrypt(plainData: testDecryptedData, keyID: shortID)
XCTAssertEqual(mockPGP.containsPublicKeyCalls, [shortID])
XCTAssertEqual(mockPGP.encryptCalls[0].keyID, shortID)
}
/// Encrypt with short ID: when containsPublicKey doesn't match and single key,
/// falls back to long fingerprint.
func testEncryptWithKeyID_shortIDNotRecognized_singleKey_resolvesToLongFingerprint() throws {
let shortID = "a1024dae"
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.publicKeyIDs = []
mockPGP.keyIDs = [longFingerprint]
_ = try agent.encrypt(plainData: testDecryptedData, keyID: shortID)
XCTAssertEqual(mockPGP.containsPublicKeyCalls, [shortID])
XCTAssertEqual(mockPGP.encryptCalls[0].keyID, longFingerprint)
}
// MARK: - Encrypt passthrough tests (for completeness of mock interaction)
/// encrypt(plainData:keyID:) calls containsPublicKey and passes data through.
func testEncryptWithKeyID_keyFound_callsInterface() throws {
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.publicKeyIDs = [longFingerprint]
let result = try agent.encrypt(plainData: testDecryptedData, keyID: longFingerprint)
XCTAssertEqual(result, mockPGP.encryptResult)
XCTAssertEqual(mockPGP.encryptCalls.count, 1)
XCTAssertEqual(mockPGP.encryptCalls[0].keyID, longFingerprint)
XCTAssertEqual(mockPGP.encryptCalls[0].plainData, testDecryptedData)
}
/// encrypt with unknown key and single available key falls back.
func testEncryptWithKeyID_keyNotFound_singleKey_fallsBack() throws {
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.publicKeyIDs = []
mockPGP.keyIDs = [longFingerprint]
let result = try agent.encrypt(plainData: testDecryptedData, keyID: "e9444483")
XCTAssertEqual(result, mockPGP.encryptResult)
XCTAssertEqual(mockPGP.encryptCalls[0].keyID, longFingerprint)
}
/// encrypt with unknown key and multiple keys throws.
func testEncryptWithKeyID_keyNotFound_multipleKeys_throws() {
mockPGP.publicKeyIDs = []
mockPGP.keyIDs = ["4712286271220db299883ea7062e678da1024dae", "787eae1a5fa3e749aa34cc6aa0645ebed862027e"]
XCTAssertThrowsError(try agent.encrypt(plainData: testDecryptedData, keyID: "a1024dae")) { error in
XCTAssertEqual(error as? AppError, AppError.pgpPublicKeyNotFound(keyID: "a1024dae"))
}
XCTAssertEqual(mockPGP.encryptCalls.count, 0)
}
/// encrypt(plainData:) without keyID passes nil to interface.
func testEncryptNoKeyID_passesNilToInterface() throws {
let result = try agent.encrypt(plainData: testDecryptedData)
XCTAssertEqual(result, mockPGP.encryptResult)
XCTAssertEqual(mockPGP.encryptCalls.count, 1)
XCTAssertNil(mockPGP.encryptCalls[0].keyID)
}
/// encrypt propagates errors from interface.
func testEncrypt_interfaceThrows_propagatesError() {
let shortID = "a1024dae"
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.publicKeyIDs = [longFingerprint]
mockPGP.encryptError = AppError.encryption
XCTAssertThrowsError(try agent.encrypt(plainData: testDecryptedData, keyID: shortID)) { error in
XCTAssertEqual(error as? AppError, AppError.encryption)
}
}
}

View file

@ -0,0 +1,70 @@
//
// MockPGPInterface.swift
// passKitTests
//
import Foundation
@testable import passKit
class MockPGPInterface: PGPInterface {
// MARK: - Configuration
var keyIDs: [String] = []
var shortKeyIDs: [String] = []
var publicKeyIDs: Set<String> = []
var privateKeyIDs: Set<String> = []
var decryptResult: Data?
var decryptError: Error?
var encryptResult = Data()
var encryptError: Error?
// MARK: - Call tracking
struct DecryptCall {
let encryptedData: Data
let keyID: String?
let passphrase: String
}
struct EncryptCall {
let plainData: Data
let keyID: String?
}
var decryptCalls: [DecryptCall] = []
var encryptCalls: [EncryptCall] = []
var containsPublicKeyCalls: [String] = []
var containsPrivateKeyCalls: [String] = []
// MARK: - PGPInterface
func decrypt(encryptedData: Data, keyID: String?, passphrase: String) throws -> Data? {
decryptCalls.append(DecryptCall(encryptedData: encryptedData, keyID: keyID, passphrase: passphrase))
if let error = decryptError {
throw error
}
return decryptResult
}
func encrypt(plainData: Data, keyID: String?) throws -> Data {
encryptCalls.append(EncryptCall(plainData: plainData, keyID: keyID))
if let error = encryptError {
throw error
}
return encryptResult
}
func containsPublicKey(with keyID: String) -> Bool {
containsPublicKeyCalls.append(keyID)
return publicKeyIDs.contains { $0.hasSuffix(keyID.lowercased()) }
}
func containsPrivateKey(with keyID: String) -> Bool {
containsPrivateKeyCalls.append(keyID)
return privateKeyIDs.contains { $0.hasSuffix(keyID.lowercased()) }
}
var keyID: [String] { keyIDs }
var shortKeyID: [String] { shortKeyIDs }
}