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