|
| 1 | +@testable import XcodesCLIKit |
| 2 | +import Foundation |
| 3 | +import XcodesLoginKit |
| 4 | +import XCTest |
| 5 | + |
| 6 | +/// `AppleSession` only exposes a `Decodable` initializer, so build the authenticated state from JSON. |
| 7 | +private let authenticatedState: AuthenticationState = { |
| 8 | + let json = Data(#"{"user":{"fullName":"Test User"}}"#.utf8) |
| 9 | + let session = try! JSONDecoder().decode(AppleSession.self, from: json) |
| 10 | + return .authenticated(session) |
| 11 | +}() |
| 12 | + |
| 13 | +final class TwoFactorAuthenticationTests: XCTestCase { |
| 14 | + override func setUp() { |
| 15 | + super.setUp() |
| 16 | + Current = .mock |
| 17 | + } |
| 18 | + |
| 19 | + private func authOptions( |
| 20 | + trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]? = nil, |
| 21 | + codeLength: Int = 6 |
| 22 | + ) -> AuthOptionsResponse { |
| 23 | + AuthOptionsResponse( |
| 24 | + trustedPhoneNumbers: trustedPhoneNumbers, |
| 25 | + trustedDevices: nil, |
| 26 | + securityCode: .init(length: codeLength) |
| 27 | + ) |
| 28 | + } |
| 29 | + |
| 30 | + private let sessionData = AppleSessionData(serviceKey: "service", sessionID: "session", scnt: "scnt") |
| 31 | + |
| 32 | + // MARK: Already authenticated |
| 33 | + |
| 34 | + func test_CompleteIfNeeded_AlreadyAuthenticated_DoesNothing() async throws { |
| 35 | + let submitCalled = LockedBox(false) |
| 36 | + let dependencies = TwoFactorAuthentication.Dependencies( |
| 37 | + submitSecurityCode: { _, _ in submitCalled.set(true); return authenticatedState }, |
| 38 | + requestSMSSecurityCode: { _, _, _ in authenticatedState } |
| 39 | + ) |
| 40 | + |
| 41 | + try await TwoFactorAuthentication.completeIfNeeded(authenticatedState, dependencies: dependencies) |
| 42 | + |
| 43 | + XCTAssertFalse(submitCalled.value) |
| 44 | + } |
| 45 | + |
| 46 | + // MARK: Trusted device code |
| 47 | + |
| 48 | + func test_CompleteIfNeeded_TrustedDeviceCode_SubmitsEnteredCode() async throws { |
| 49 | + Current.shell.readLine = { _ in "123456" } |
| 50 | + |
| 51 | + let submittedCode = LockedBox<SecurityCode?>(nil) |
| 52 | + let dependencies = TwoFactorAuthentication.Dependencies( |
| 53 | + submitSecurityCode: { code, _ in submittedCode.set(code); return authenticatedState }, |
| 54 | + requestSMSSecurityCode: { _, _, _ in XCTFail("Should not request SMS"); return authenticatedState } |
| 55 | + ) |
| 56 | + |
| 57 | + let state = AuthenticationState.waitingForSecondFactor(.codeSent, authOptions(), sessionData) |
| 58 | + try await TwoFactorAuthentication.completeIfNeeded(state, dependencies: dependencies) |
| 59 | + |
| 60 | + guard case let .device(code) = submittedCode.value else { |
| 61 | + return XCTFail("Expected a trusted-device code, got \(String(describing: submittedCode.value))") |
| 62 | + } |
| 63 | + XCTAssertEqual(code, "123456") |
| 64 | + } |
| 65 | + |
| 66 | + func test_CompleteIfNeeded_TrustedDeviceCode_EnteringSMS_FallsBackToPhoneSelection() async throws { |
| 67 | + // First prompt (device code) -> "sms"; second prompt (phone selection) -> "1"; third prompt (SMS code) -> "654321". |
| 68 | + let scripted = ["sms", "1", "654321"] |
| 69 | + let index = LockedBox(0) |
| 70 | + Current.shell.readLine = { _ in |
| 71 | + let i = index.incrementAfterRead() |
| 72 | + return i < scripted.count ? scripted[i] : nil |
| 73 | + } |
| 74 | + |
| 75 | + let smsRequested = LockedBox(false) |
| 76 | + let submittedCode = LockedBox<SecurityCode?>(nil) |
| 77 | + let phoneNumber = AuthOptionsResponse.TrustedPhoneNumber(id: 7, numberWithDialCode: "+1 (•••) •••-1234") |
| 78 | + let dependencies = TwoFactorAuthentication.Dependencies( |
| 79 | + submitSecurityCode: { code, _ in submittedCode.set(code); return authenticatedState }, |
| 80 | + requestSMSSecurityCode: { _, _, _ in smsRequested.set(true); return authenticatedState } |
| 81 | + ) |
| 82 | + |
| 83 | + let state = AuthenticationState.waitingForSecondFactor(.codeSent, authOptions(trustedPhoneNumbers: [phoneNumber]), sessionData) |
| 84 | + try await TwoFactorAuthentication.completeIfNeeded(state, dependencies: dependencies) |
| 85 | + |
| 86 | + XCTAssertTrue(smsRequested.value) |
| 87 | + guard case let .sms(code, phoneNumberId) = submittedCode.value else { |
| 88 | + return XCTFail("Expected an SMS code, got \(String(describing: submittedCode.value))") |
| 89 | + } |
| 90 | + XCTAssertEqual(code, "654321") |
| 91 | + XCTAssertEqual(phoneNumberId, 7) |
| 92 | + } |
| 93 | + |
| 94 | + // MARK: SMS automatically sent |
| 95 | + |
| 96 | + func test_CompleteIfNeeded_SMSSent_SubmitsCodeForThatNumber() async throws { |
| 97 | + Current.shell.readLine = { _ in "987654" } |
| 98 | + |
| 99 | + let phoneNumber = AuthOptionsResponse.TrustedPhoneNumber(id: 3, numberWithDialCode: "+1 (•••) •••-9999") |
| 100 | + let submittedCode = LockedBox<SecurityCode?>(nil) |
| 101 | + let dependencies = TwoFactorAuthentication.Dependencies( |
| 102 | + submitSecurityCode: { code, _ in submittedCode.set(code); return authenticatedState }, |
| 103 | + requestSMSSecurityCode: { _, _, _ in XCTFail("SMS already sent automatically"); return authenticatedState } |
| 104 | + ) |
| 105 | + |
| 106 | + let state = AuthenticationState.waitingForSecondFactor(.smsSent(phoneNumber), authOptions(trustedPhoneNumbers: [phoneNumber]), sessionData) |
| 107 | + try await TwoFactorAuthentication.completeIfNeeded(state, dependencies: dependencies) |
| 108 | + |
| 109 | + guard case let .sms(code, phoneNumberId) = submittedCode.value else { |
| 110 | + return XCTFail("Expected an SMS code, got \(String(describing: submittedCode.value))") |
| 111 | + } |
| 112 | + XCTAssertEqual(code, "987654") |
| 113 | + XCTAssertEqual(phoneNumberId, 3) |
| 114 | + } |
| 115 | + |
| 116 | + // MARK: SMS phone number selection |
| 117 | + |
| 118 | + func test_CompleteIfNeeded_SMSPendingChoice_RequestsAndSubmitsForSelectedNumber() async throws { |
| 119 | + let scripted = ["2", "111222"] |
| 120 | + let index = LockedBox(0) |
| 121 | + Current.shell.readLine = { _ in |
| 122 | + let i = index.incrementAfterRead() |
| 123 | + return i < scripted.count ? scripted[i] : nil |
| 124 | + } |
| 125 | + |
| 126 | + let phoneNumbers = [ |
| 127 | + AuthOptionsResponse.TrustedPhoneNumber(id: 1, numberWithDialCode: "+1 (•••) •••-1111"), |
| 128 | + AuthOptionsResponse.TrustedPhoneNumber(id: 2, numberWithDialCode: "+1 (•••) •••-2222"), |
| 129 | + ] |
| 130 | + let requestedPhoneID = LockedBox<Int?>(nil) |
| 131 | + let submittedCode = LockedBox<SecurityCode?>(nil) |
| 132 | + let dependencies = TwoFactorAuthentication.Dependencies( |
| 133 | + submitSecurityCode: { code, _ in submittedCode.set(code); return authenticatedState }, |
| 134 | + requestSMSSecurityCode: { phone, _, _ in requestedPhoneID.set(phone.id); return authenticatedState } |
| 135 | + ) |
| 136 | + |
| 137 | + let state = AuthenticationState.waitingForSecondFactor(.smsPendingChoice, authOptions(trustedPhoneNumbers: phoneNumbers), sessionData) |
| 138 | + try await TwoFactorAuthentication.completeIfNeeded(state, dependencies: dependencies) |
| 139 | + |
| 140 | + XCTAssertEqual(requestedPhoneID.value, 2) |
| 141 | + guard case let .sms(code, phoneNumberId) = submittedCode.value else { |
| 142 | + return XCTFail("Expected an SMS code, got \(String(describing: submittedCode.value))") |
| 143 | + } |
| 144 | + XCTAssertEqual(code, "111222") |
| 145 | + XCTAssertEqual(phoneNumberId, 2) |
| 146 | + } |
| 147 | + |
| 148 | + func test_CompleteIfNeeded_SMSPendingChoice_InvalidSelection_RetriesUntilValid() async throws { |
| 149 | + // "0" and "9" are out of range, then "1" selects the first number. |
| 150 | + let scripted = ["0", "9", "1", "555000"] |
| 151 | + let index = LockedBox(0) |
| 152 | + Current.shell.readLine = { _ in |
| 153 | + let i = index.incrementAfterRead() |
| 154 | + return i < scripted.count ? scripted[i] : nil |
| 155 | + } |
| 156 | + |
| 157 | + let phoneNumber = AuthOptionsResponse.TrustedPhoneNumber(id: 5, numberWithDialCode: "+1 (•••) •••-5555") |
| 158 | + let requestedPhoneID = LockedBox<Int?>(nil) |
| 159 | + let dependencies = TwoFactorAuthentication.Dependencies( |
| 160 | + submitSecurityCode: { _, _ in authenticatedState }, |
| 161 | + requestSMSSecurityCode: { phone, _, _ in requestedPhoneID.set(phone.id); return authenticatedState } |
| 162 | + ) |
| 163 | + |
| 164 | + let state = AuthenticationState.waitingForSecondFactor(.smsPendingChoice, authOptions(trustedPhoneNumbers: [phoneNumber]), sessionData) |
| 165 | + try await TwoFactorAuthentication.completeIfNeeded(state, dependencies: dependencies) |
| 166 | + |
| 167 | + XCTAssertEqual(requestedPhoneID.value, 5) |
| 168 | + } |
| 169 | + |
| 170 | + // MARK: Unsupported / error states |
| 171 | + |
| 172 | + func test_CompleteIfNeeded_SecurityKey_Throws() async { |
| 173 | + let dependencies = TwoFactorAuthentication.Dependencies( |
| 174 | + submitSecurityCode: { _, _ in authenticatedState }, |
| 175 | + requestSMSSecurityCode: { _, _, _ in authenticatedState } |
| 176 | + ) |
| 177 | + |
| 178 | + // securityKey requires an fsaChallenge in a real response, but the handler rejects it before |
| 179 | + // inspecting authOptions, so an empty options object is sufficient here. |
| 180 | + let state = AuthenticationState.waitingForSecondFactor(.securityKey, authOptions(), sessionData) |
| 181 | + |
| 182 | + do { |
| 183 | + try await TwoFactorAuthentication.completeIfNeeded(state, dependencies: dependencies) |
| 184 | + XCTFail("Expected security-key handling to throw") |
| 185 | + } catch { |
| 186 | + // Expected. |
| 187 | + } |
| 188 | + } |
| 189 | + |
| 190 | + func test_CompleteIfNeeded_NotAppleDeveloper_Throws() async { |
| 191 | + let dependencies = TwoFactorAuthentication.Dependencies( |
| 192 | + submitSecurityCode: { _, _ in authenticatedState }, |
| 193 | + requestSMSSecurityCode: { _, _, _ in authenticatedState } |
| 194 | + ) |
| 195 | + |
| 196 | + do { |
| 197 | + try await TwoFactorAuthentication.completeIfNeeded(.notAppleDeveloper, dependencies: dependencies) |
| 198 | + XCTFail("Expected notAppleDeveloper to throw") |
| 199 | + } catch { |
| 200 | + XCTAssertEqual(error as? AuthenticationError, .notDeveloperAppleId) |
| 201 | + } |
| 202 | + } |
| 203 | +} |
0 commit comments