passforios/passKitTests/LowLevel/PGPAgentLowLevelTests.swift

662 lines
33 KiB
Swift
Raw Normal View History

//
// 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)
}
}
}