pi-remote-ios/Tests/CoreTests/KeychainTests.swift

152 lines
6.1 KiB
Swift

// KeychainTests.swift
// Unit tests for the Keychain wrapper.
//
// IMPORTANT: Each test uses a unique key (never the production key
// `Keychain.credentialKey`) so real app data is never touched.
// All test keys are deleted in tearDown even if the test fails.
//
// Note: Keychain tests require the iOS simulator or a real device with
// a valid entitlement. They will fail in headless environments where
// the Security framework is unavailable.
import XCTest
@testable import piRemote
final class KeychainTests: XCTestCase {
// MARK: - Helpers
/// Unique per-run key prefix so parallel test runs don't collide.
private var testKey: String { "test.keychain.\(name)" }
override func tearDown() {
// Always clean up the test key to avoid polluting the Keychain.
Keychain.shared.delete(key: testKey)
super.tearDown()
}
/// A minimal `SidecarCredential` for testing.
private func makeCredential(name: String = "test-pi") -> SidecarCredential {
SidecarCredential(
sidecarId: "sid-\(UUID().uuidString)",
host: "192.168.1.100",
port: 7777,
bearerToken: "bearer-\(UUID().uuidString)",
tlsFingerprint: "deadbeef1234",
sidecarName: name,
pairedAt: Date(timeIntervalSince1970: 1_716_000_000)
)
}
// =========================================================================
// MARK: 1. Save + load round-trip
// =========================================================================
func testSaveAndLoad_credential_roundTrips() throws {
let credential = makeCredential(name: "my-pi")
try Keychain.shared.save(credential, key: testKey)
let loaded: SidecarCredential = try Keychain.shared.load(key: testKey)
XCTAssertEqual(loaded.sidecarId, credential.sidecarId)
XCTAssertEqual(loaded.host, credential.host)
XCTAssertEqual(loaded.port, credential.port)
XCTAssertEqual(loaded.bearerToken, credential.bearerToken)
XCTAssertEqual(loaded.tlsFingerprint, credential.tlsFingerprint)
XCTAssertEqual(loaded.sidecarName, credential.sidecarName)
// Date comparison within 1 second tolerance (JSON Date encoding).
XCTAssertEqual(loaded.pairedAt.timeIntervalSince1970,
credential.pairedAt.timeIntervalSince1970,
accuracy: 1.0)
}
// =========================================================================
// MARK: 2. Update (upsert)
// =========================================================================
func testSave_overwritesPrevious_onLoad() throws {
let first = makeCredential(name: "first-pi")
let second = makeCredential(name: "second-pi")
try Keychain.shared.save(first, key: testKey)
try Keychain.shared.save(second, key: testKey)
let loaded: SidecarCredential = try Keychain.shared.load(key: testKey)
XCTAssertEqual(loaded.sidecarName, "second-pi",
"Second save must overwrite the first")
}
// =========================================================================
// MARK: 3. Missing key notFound
// =========================================================================
func testLoad_missingKey_throwsNotFound() {
let missingKey = "test.keychain.definitely-absent-\(UUID())"
defer { Keychain.shared.delete(key: missingKey) }
XCTAssertThrowsError(try Keychain.shared.load(key: missingKey) as SidecarCredential) { error in
guard let keychainError = error as? KeychainError,
case .notFound = keychainError else {
XCTFail("Expected KeychainError.notFound, got \(error)")
return
}
}
}
// =========================================================================
// MARK: 4. Delete clears entry
// =========================================================================
func testDelete_clearsEntry() throws {
try Keychain.shared.save(makeCredential(), key: testKey)
Keychain.shared.delete(key: testKey)
XCTAssertThrowsError(try Keychain.shared.load(key: testKey) as SidecarCredential,
"After delete, load must throw") { error in
guard let keychainError = error as? KeychainError,
case .notFound = keychainError else {
XCTFail("Expected KeychainError.notFound after delete, got \(error)")
return
}
}
}
func testDelete_missingKey_isNoOp() {
// Deleting a key that doesn't exist must not crash or throw.
Keychain.shared.delete(key: "test.keychain.never-saved-\(UUID())")
// No assertion needed reaching this line means no crash.
}
// =========================================================================
// MARK: 5. Production key is NOT used in tests
// =========================================================================
func testProductionKeyIsUntouched() {
// This test simply verifies that our test key is different from the
// production credential key, so tests never corrupt real data.
XCTAssertNotEqual(testKey, Keychain.credentialKey,
"Test key must differ from the production credential key")
}
// =========================================================================
// MARK: 6. Generic save/load with a simple Codable type
// =========================================================================
private struct TestPayload: Codable, Equatable {
let id: String
let value: Int
}
func testSaveAndLoad_simplePayload() throws {
let key = "test.keychain.payload.\(UUID())"
let payload = TestPayload(id: "x", value: 99)
defer { Keychain.shared.delete(key: key) }
try Keychain.shared.save(payload, key: key)
let loaded: TestPayload = try Keychain.shared.load(key: key)
XCTAssertEqual(loaded, payload)
}
}