From b2bc1e245eb6c7d9f4056bdf5aa736363cfa4cda Mon Sep 17 00:00:00 2001 From: noppe Date: Sun, 20 Oct 2024 22:35:16 +0900 Subject: [PATCH 1/2] Support Service Temporarily Unavailable response error --- Sources/AppleAPI/Client.swift | 38 ++++-- Tests/AppleAPITests/AppleAPITests.swift | 90 ++++++++++++ .../AuthOptions.json | 34 +++++ .../Federate.json | 3 + .../ITCServiceKey.json | 4 + .../OlympusSession.json | 128 ++++++++++++++++++ .../SignIn.json | 15 ++ 7 files changed, 297 insertions(+), 15 deletions(-) create mode 100644 Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/AuthOptions.json create mode 100644 Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/Federate.json create mode 100644 Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/ITCServiceKey.json create mode 100644 Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/OlympusSession.json create mode 100644 Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/SignIn.json diff --git a/Sources/AppleAPI/Client.swift b/Sources/AppleAPI/Client.swift index e145f27..eac9f89 100644 --- a/Sources/AppleAPI/Client.swift +++ b/Sources/AppleAPI/Client.swift @@ -15,6 +15,7 @@ public class Client { case incorrectSecurityCode case unexpectedSignInResponse(statusCode: Int, message: String?) case appleIDAndPrivacyAcknowledgementRequired + case serviceTemporarilyUnavailable case noTrustedPhoneNumbers case notAuthenticated case invalidHashcash @@ -27,6 +28,8 @@ public class Client { return "Invalid username and password combination. Attempted to sign in with username \(username)." case .appleIDAndPrivacyAcknowledgementRequired: return "You must sign in to https://appstoreconnect.apple.com and acknowledge the Apple ID & Privacy agreement." + case .serviceTemporarilyUnavailable: + return "The service is temporarily unavailable. Please try again later." case .invalidPhoneNumberIndex(let min, let max, let given): return "Not a valid phone number index. Expecting a whole number between \(min)-\(max), but was given \(given ?? "nothing")." case .noTrustedPhoneNumbers: @@ -67,7 +70,7 @@ public class Client { let authServiceKey: String? } - let response = try JSONDecoder().decode(ServiceKeyResponse.self, from: data) + let response = try! JSONDecoder().decode(ServiceKeyResponse.self, from: data) serviceKey = response.authServiceKey return self.loadHashcash(accountName: accountName, serviceKey: serviceKey).map { (serviceKey, $0) } @@ -92,20 +95,25 @@ public class Client { } let httpResponse = response as! HTTPURLResponse - let responseBody = try JSONDecoder().decode(SignInResponse.self, from: data) - - switch httpResponse.statusCode { - case 200: - return Current.network.dataTask(with: URLRequest.olympusSession).asVoid() - case 401: - throw Error.invalidUsernameOrPassword(username: accountName) - case 409: - return self.handleTwoStepOrFactor(data: data, response: response, serviceKey: serviceKey) - case 412 where Client.authTypes.contains(responseBody.authType ?? ""): - throw Error.appleIDAndPrivacyAcknowledgementRequired - default: - throw Error.unexpectedSignInResponse(statusCode: httpResponse.statusCode, - message: responseBody.serviceErrors?.map { $0.description }.joined(separator: ", ")) + do { + let responseBody = try JSONDecoder().decode(SignInResponse.self, from: data) + switch httpResponse.statusCode { + case 200: + return Current.network.dataTask(with: URLRequest.olympusSession).asVoid() + case 401: + throw Error.invalidUsernameOrPassword(username: accountName) + case 409: + return self.handleTwoStepOrFactor(data: data, response: response, serviceKey: serviceKey) + case 412 where Client.authTypes.contains(responseBody.authType ?? ""): + throw Error.appleIDAndPrivacyAcknowledgementRequired + default: + throw Error.unexpectedSignInResponse(statusCode: httpResponse.statusCode, + message: responseBody.serviceErrors?.map { $0.description }.joined(separator: ", ")) + } + } catch DecodingError.dataCorrupted where httpResponse.statusCode == 503 { + throw Error.serviceTemporarilyUnavailable + } catch { + throw error } } } diff --git a/Tests/AppleAPITests/AppleAPITests.swift b/Tests/AppleAPITests/AppleAPITests.swift index b879ff3..06c7f36 100644 --- a/Tests/AppleAPITests/AppleAPITests.swift +++ b/Tests/AppleAPITests/AppleAPITests.swift @@ -661,6 +661,96 @@ final class AppleAPITests: XCTestCase { """) } + func test_Login_Service_Temporarily_Unavailable() { + var log = "" + Current.logging.log = { log.append($0 + "\n") } + + var readLineCount = 0 + Current.shell.readLine = { prompt in + defer { readLineCount += 1 } + + Current.logging.log(prompt) + + // security code + return "000000" + } + + Current.network.dataTask = { convertible in + + switch convertible.pmkRequest.url! { + case .itcServiceKey: + return fixture(for: .itcServiceKey, + fileURL: Bundle.module.url(forResource: "ITCServiceKey", withExtension: "json", subdirectory: "Fixtures/Login_Service_Temporarily_Unavailable")!, + statusCode: 200, + headers: ["Content-Type": "application/json"]) + case .signIn: + if convertible.pmkRequest.httpMethod == "GET" { + return fixture(for: .signIn, + fileURL: Bundle.module.url(forResource: "Federate", withExtension: "json", subdirectory: "Fixtures/Login_Service_Temporarily_Unavailable")!, + statusCode: 200, + headers: ["Content-Type": "application/json", + "X-Apple-HC-Bits": "10", + "X-Apple-HC-Challenge": "somestring", + "scnt": ""]) + } else { + return fixture(for: .signIn, + fileURL: Bundle.module.url(forResource: "SignIn", withExtension: "json", subdirectory: "Fixtures/Login_Service_Temporarily_Unavailable")!, + statusCode: 503, + headers: ["Content-Type": "text/html", + "X-Apple-ID-Session-Id": "", + "scnt": ""]) + } + case .authOptions: + return fixture(for: .authOptions, + fileURL: Bundle.module.url(forResource: "AuthOptions", withExtension: "json", subdirectory: "Fixtures/Login_Service_Temporarily_Unavailable")!, + statusCode: 200, + headers: ["Content-Type": "application/json", + "X-Apple-ID-Session-Id": "", + "scnt": ""]) + case .submitSecurityCode(.device(code: "000000")): + return fixture(for: .submitSecurityCode(.device(code: "000000")), + statusCode: 204, + headers: ["Content-Type": "application/json", + "X-Apple-ID-Session-Id": "", + "scnt": ""]) + case .trust: + return fixture(for: .trust, + statusCode: 204, + headers: [:]) + case .olympusSession: + return fixture(for: .olympusSession, + fileURL: Bundle.module.url(forResource: "OlympusSession", withExtension: "json", subdirectory: "Fixtures/Login_Service_Temporarily_Unavailable")!, + statusCode: 200, + headers: ["Content-Type": "application/json", + "X-Apple-ID-Session-Id": "", + "scnt": ""]) + default: + print(convertible.pmkRequest.url!) + XCTFail() + return .init(error: PMKError.invalidCallingConvention) + } + } + + let expectation = self.expectation(description: "promise fulfills") + + let client = Client() + client.login(accountName: "test@example.com", password: "ABC123") + .tap { result in + guard case .rejected(let error as AppleAPI.Client.Error) = result else { + XCTFail("login fulfilled, but should have rejected with .noTrustedPhoneNumbers error") + return + } + XCTAssertEqual(error, AppleAPI.Client.Error.serviceTemporarilyUnavailable) + expectation.fulfill() + } + .cauterize() + + wait(for: [expectation], timeout: 1.0) + + XCTAssertEqual(log, "") + } + + func testValidHashCashMint() { let bits: UInt = 11 let resource = "4d74fb15eb23f465f1f6fcbf534e5877" diff --git a/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/AuthOptions.json b/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/AuthOptions.json new file mode 100644 index 0000000..f521ff8 --- /dev/null +++ b/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/AuthOptions.json @@ -0,0 +1,34 @@ +{ + "trustedPhoneNumbers" : [ { + "obfuscatedNumber" : "(•••) •••-••00", + "pushMode" : "sms", + "numberWithDialCode" : "+1 (•••) •••-••00", + "id" : 1 + } ], + "securityCode" : { + "length" : 6, + "tooManyCodesSent" : false, + "tooManyCodesValidated" : false, + "securityCodeLocked" : false, + "securityCodeCooldown" : false + }, + "authenticationType" : "hsa2", + "recoveryUrl" : "https://iforgot.apple.com/phone/add?prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", + "cantUsePhoneNumberUrl" : "https://iforgot.apple.com/iforgot/phone/add?context=cantuse&prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", + "recoveryWebUrl" : "https://iforgot.apple.com/password/verify/appleid?prs_account_nm=test%40example.com&autoSubmitAccount=true&appId=142", + "repairPhoneNumberUrl" : "https://gsa.apple.com/appleid/account/manage/repair/verify/phone", + "repairPhoneNumberWebUrl" : "https://appleid.apple.com/widget/account/repair?#!repair", + "aboutTwoFactorAuthenticationUrl" : "https://support.apple.com/kb/HT204921", + "autoVerified" : false, + "showAutoVerificationUI" : false, + "managedAccount" : false, + "trustedPhoneNumber" : { + "obfuscatedNumber" : "(•••) •••-••00", + "pushMode" : "sms", + "numberWithDialCode" : "+1 (•••) •••-••00", + "id" : 1 + }, + "hsa2Account" : true, + "restrictedAccount" : false, + "supportsRecovery" : true +} diff --git a/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/Federate.json b/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/Federate.json new file mode 100644 index 0000000..10fb36e --- /dev/null +++ b/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/Federate.json @@ -0,0 +1,3 @@ +{ + "authType" : "hsa2" +} diff --git a/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/ITCServiceKey.json b/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/ITCServiceKey.json new file mode 100644 index 0000000..33c00bf --- /dev/null +++ b/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/ITCServiceKey.json @@ -0,0 +1,4 @@ +{ + "authServiceUrl" : "https://idmsa.apple.com/appleauth", + "authServiceKey" : "NNNNN" +} diff --git a/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/OlympusSession.json b/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/OlympusSession.json new file mode 100644 index 0000000..07fb0cb --- /dev/null +++ b/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/OlympusSession.json @@ -0,0 +1,128 @@ +{ + "user" : { + "fullName" : "Test User", + "firstName" : "Test", + "lastName" : "User", + "emailAddress" : "test@example.com", + "prsId" : "000000000" + }, + "provider" : { + "providerId" : 00000, + "name" : "Test User", + "contentTypes" : [ "SOFTWARE" ], + "subType" : "INDIVIDUAL", + "pla" : [ { + "id" : "1BC01216-52D4-43DC-8555-195F4454C348", + "version" : "5014", + "types" : [ "contractContentTypeDisplay.iOSFreeApps", "contractContentTypeDisplay.MacOSXFreeApplications" ], + "contractCountryOfOrigins" : [ "CAN" ] + } ] + }, + "theme" : "APPSTORE_CONNECT", + "availableProviders" : [ { + "providerId" : 000000, + "name" : "Test User", + "contentTypes" : [ "SOFTWARE" ], + "subType" : "INDIVIDUAL" + } ], + "backingType" : "ITC", + "backingTypes" : [ "ITC" ], + "roles" : [ "ADMIN", "LEGAL" ], + "unverifiedRoles" : [ ], + "featureFlags" : [ "showWwdrUserRoles", "adpRad", "apiKeys" ], + "agreeToTerms" : true, + "termsSignatures" : [ "ASC", "RAD" ], + "modules" : [ { + "key" : "Apps", + "name" : "ITC.HomePage.Apps.IconText", + "localizedName" : "My Apps", + "url" : "https://appstoreconnect.apple.com/apps", + "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/Apps@2x.d3ce493e56172e92aed6.png", + "down" : false, + "visible" : true, + "hasNotifications" : false + }, { + "key" : "AppAnalytics", + "name" : "ITC.HomePage.AppAnalytics.IconText", + "localizedName" : "App Analytics", + "url" : "https://analytics.itunes.apple.com/", + "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/AppAnalytics@2x.e19f711d943cb42d65b2.png", + "down" : false, + "visible" : true, + "hasNotifications" : false + }, { + "key" : "SalesTrends", + "name" : "ITC.HomePage.SalesTrends.IconText", + "localizedName" : "Sales and Trends", + "url" : "https://appstoreconnect.apple.com/trends", + "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/SalesTrends@2x.b1f802112426525d990a.png", + "down" : false, + "visible" : true, + "hasNotifications" : false + }, { + "key" : "FinancialReports", + "name" : "ITC.HomePage.FinancialReports.IconText", + "localizedName" : "Payments and Financial Reports", + "url" : "https://appstoreconnect.apple.com/itc/payments_and_financial_reports", + "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/FinancialReports@2x.a7b266a5136cc65c643b.png", + "down" : false, + "visible" : true, + "hasNotifications" : false + }, { + "key" : "Account", + "name" : "ITC.HomePage.Account.IconText", + "localizedName" : "Users and Access", + "url" : "https://appstoreconnect.apple.com/access/users", + "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/ManageUsers@2x.81511f3933fb2fb4b20d.png", + "down" : false, + "visible" : true, + "hasNotifications" : false + }, { + "key" : "ContractsTaxBanking", + "name" : "ITC.HomePage.ContractsTaxBanking.IconText", + "localizedName" : "Agreements, Tax, and Banking", + "url" : "https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/da/jumpTo?page=contracts", + "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/ContractsTaxBanking@2x.74466eb8570dd797602e.png", + "down" : false, + "visible" : true, + "hasNotifications" : false + }, { + "key" : "Resources", + "name" : "ITC.HomePage.Resources.IconText", + "localizedName" : "Resources and Help", + "url" : "https://developer.apple.com/app-store-connect/", + "iconUrl" : "https://appstoreconnect.apple.com/static/img/ico_homepage/themed/apps/Resources@2x.3c8d0d8c08e876cf9470.png", + "down" : false, + "visible" : true, + "hasNotifications" : false + } ], + "helpLinks" : [ { + "key" : "AllAsc", + "url" : "https://help.apple.com/app-store-connect/", + "localizedText" : "App Store Connect Resources" + }, { + "key" : "Xcode", + "url" : "https://help.apple.com/xcode/mac/current/", + "localizedText" : "Xcode Help" + }, { + "key" : "SupportContact", + "url" : "https://developer.apple.com/support/", + "localizedText" : "Support and Contact" + } ], + "userProfile" : [ { + "key" : "signIn", + "url" : "https://appstoreconnect.apple.com/login", + "localizedText" : "Sign In" + }, { + "key" : "personalDetails", + "url" : "https://appstoreconnect.apple.com/access/users/07E3E586-44B1-48D3-BF8D-430F754F1BAA/settings", + "localizedText" : "Edit Profile" + }, { + "key" : "signOut", + "url" : "https://appstoreconnect.apple.com/logout", + "localizedText" : "Sign Out" + } ], + "pccDto" : null, + "publicUserId" : "07E3E586-44B1-48D3-BF8D-430F754F1BAA", + "ofacState" : null +} diff --git a/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/SignIn.json b/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/SignIn.json new file mode 100644 index 0000000..51e66d3 --- /dev/null +++ b/Tests/AppleAPITests/Fixtures/Login_Service_Temporarily_Unavailable/SignIn.json @@ -0,0 +1,15 @@ + + + + 503 Service Temporarily Unavailable + + + +
+

503 Service Temporarily Unavailable

+
+
+
Apple
+ + + From d6d00b2f753090d1262f430a25b1ee4ab3d46b3d Mon Sep 17 00:00:00 2001 From: noppe Date: Sun, 20 Oct 2024 22:39:11 +0900 Subject: [PATCH 2/2] Update Client.swift --- Sources/AppleAPI/Client.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AppleAPI/Client.swift b/Sources/AppleAPI/Client.swift index eac9f89..b9ba89a 100644 --- a/Sources/AppleAPI/Client.swift +++ b/Sources/AppleAPI/Client.swift @@ -70,7 +70,7 @@ public class Client { let authServiceKey: String? } - let response = try! JSONDecoder().decode(ServiceKeyResponse.self, from: data) + let response = try JSONDecoder().decode(ServiceKeyResponse.self, from: data) serviceKey = response.authServiceKey return self.loadHashcash(accountName: accountName, serviceKey: serviceKey).map { (serviceKey, $0) }