// AccountAPIClientTests.swift // OSGKeyboardTests // // Hermetic tests for session rotation, retry limits, and stable API errors. @testable import OSGKeyboardHostSupport import OSGKeyboardShared import XCTest final class AccountAPIClientTests: XCTestCase { func testAppleSignInDecodesEnvelopeAndStoresWholeSession() async throws { let expected = makeAccountSession() let transport = QueueAccountTransport([ .init(statusCode: 200, body: try sessionEnvelopeData(expected)) ]) let store = InMemoryAccountSecurityStore( refreshTransaction: AccountRefreshTransaction( refreshTokenDigest: "stale", operationId: UUID() ) ) let client = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store ) let result = try await client.signInWithApple( AppleSignInRequest( identityToken: "identity", authorizationCode: "authorization", nonce: "raw-nonce", deviceCheckToken: "device-token", appAttest: nil ) ) XCTAssertEqual(result, expected) let stored = await store.session let refreshTransaction = await store.refreshTransaction XCTAssertEqual(stored, expected) XCTAssertNil(refreshTransaction) let requests = await transport.requests XCTAssertEqual(requests.single?.url?.path, "/v1/auth/apple") XCTAssertNil(requests.single?.value(forHTTPHeaderField: "Authorization")) } func testOOBEGrantUsesAnonymousEndpointAndExactAttestedBody() async throws { let installationID = UUID(uuidString: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")! let challengeID = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! let transport = QueueAccountTransport([ .init( statusCode: 201, body: Data( """ {"grantId":"oobe-grant","scopes":["polish","ai"], "accessToken":"oobe-access","accessExpiresAt":"2030-01-01T00:05:00Z", "refreshToken":"oobe-refresh","refreshExpiresAt":"2030-01-01T01:00:00Z"} """.utf8 ) ) ]) let store = InMemoryAccountSecurityStore() let client = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store, now: { Date(timeIntervalSince1970: 1_000) } ) let requestBody = OOBEGrantRequest( installationId: installationID, keyId: "app-attest-key", challengeId: challengeID, challenge: "AQID", assertion: "BAUG" ) let credentials = try await client.requestOOBEGrant(requestBody) XCTAssertEqual(credentials.grantId, "oobe-grant") XCTAssertEqual(credentials.scopes, [.polish, .assistant]) let requests = await transport.requests let request = try XCTUnwrap(requests.single) XCTAssertEqual(request.url?.path, "/v1/oobe/grants") XCTAssertEqual(request.httpMethod, "POST") XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) let json = try XCTUnwrap( JSONSerialization.jsonObject( with: try XCTUnwrap(request.httpBody) ) as? [String: Any] ) XCTAssertEqual( Set(json.keys), ["installationId", "keyId", "challengeId", "challenge", "assertion"] ) XCTAssertEqual(json["installationId"] as? String, installationID.uuidString) XCTAssertEqual(json["keyId"] as? String, "app-attest-key") } func testNicknameUpdateUsesAuthenticatedPatch() async throws { let session = makeAccountSession() let body = Data( """ {"data":{"id":"\(session.accountId.uuidString)","createdAtEpochSeconds":1,"displayName":"OSG 用户"}} """.utf8 ) let transport = QueueAccountTransport([.init(statusCode: 200, body: body)]) let store = InMemoryAccountSecurityStore(session: session) let client = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store ) let account = try await client.updateAccount(displayName: "OSG 用户") let requests = await transport.requests XCTAssertEqual(account.displayName, "OSG 用户") XCTAssertEqual(requests.single?.httpMethod, "PATCH") XCTAssertEqual(requests.single?.url?.path, "/v1/account") XCTAssertEqual(String(data: requests.single?.httpBody ?? Data(), encoding: .utf8), """ {"displayName":"OSG 用户"} """) } func testUnauthorizedRequestRefreshesAndRetriesExactlyOnce() async throws { let old = makeAccountSession() let replacement = makeAccountSession( accessToken: "access-new", refreshToken: "refresh-new" ) let transport = QueueAccountTransport([ .init(statusCode: 401, body: apiErrorData(code: "unauthorized", message: "expired")), .init(statusCode: 200, body: try sessionEnvelopeData(replacement)), .init(statusCode: 401, body: apiErrorData(code: "unauthorized", message: "still denied")) ]) let store = InMemoryAccountSecurityStore(session: old) let client = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store ) let invalidations = await client.sessionInvalidations() let invalidationTask = Task { var iterator = invalidations.makeAsyncIterator() return await iterator.next() } do { _ = try await client.account() XCTFail("Expected the single retry to fail") } catch let error as AccountAPIError { XCTAssertEqual(error, .unauthorized("still denied")) } let requests = await transport.requests XCTAssertEqual(requests.map(\.url?.path), [ "/v1/account", "/v1/auth/refresh", "/v1/account" ]) XCTAssertEqual( requests.last?.value(forHTTPHeaderField: "Authorization"), "Bearer access-new" ) let refreshRequest = try XCTUnwrap( requests.first { $0.url?.path == "/v1/auth/refresh" } ) XCTAssertNotNil(try refreshOperationID(from: refreshRequest)) let stored = await store.session let clearCount = await store.clearSessionCount XCTAssertNil(stored) XCTAssertEqual(clearCount, 1) let invalidation = await invalidationTask.value guard let invalidation else { return XCTFail("Expected a session invalidation event") } switch invalidation { case .expired: break } } func testAuthorizedAccessTokenRefreshesAnExpiringCachedSession() async throws { let old = makeAccountSession(accessExpiry: 1_020) let replacement = makeAccountSession( accessToken: "access-fresh", refreshToken: "refresh-fresh" ) let transport = QueueAccountTransport([ .init(statusCode: 200, body: try sessionEnvelopeData(replacement)) ]) let store = InMemoryAccountSecurityStore(session: old) let client = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store, now: { Date(timeIntervalSince1970: 1_000) } ) let token = try await client.accessTokenForAuthorizedRequest() XCTAssertEqual(token, "access-fresh") let requests = await transport.requests let refreshRequest = try XCTUnwrap(requests.single) XCTAssertEqual(refreshRequest.url?.path, "/v1/auth/refresh") XCTAssertNotNil(try refreshOperationID(from: refreshRequest)) let transaction = await store.refreshTransaction XCTAssertNotNil(transaction) } func testRefreshSaveFailureRetainsOldSessionAndReusesPersistedOperationID() async throws { let old = makeAccountSession(accessExpiry: 1_020) let replacement = makeAccountSession( accessToken: "access-recovered", refreshToken: "refresh-recovered" ) let transport = QueueAccountTransport([ .init(statusCode: 200, body: try sessionEnvelopeData(replacement)), .init(statusCode: 200, body: try sessionEnvelopeData(replacement)) ]) let store = InMemoryAccountSecurityStore( session: old, sessionSaveFailures: 1 ) let firstClient = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store, now: { Date(timeIntervalSince1970: 1_000) } ) do { _ = try await firstClient.accessTokenForAuthorizedRequest() XCTFail("Expected the first Keychain commit to fail") } catch let error as AccountAPIError { XCTAssertEqual(error, .secureStorage) } let retainedSession = await store.session let retainedTransaction = await store.refreshTransaction let clearCount = await store.clearSessionCount XCTAssertEqual(retainedSession, old) XCTAssertNotNil(retainedTransaction) XCTAssertEqual(clearCount, 0) let recreatedClient = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store, now: { Date(timeIntervalSince1970: 1_000) } ) let recoveredToken = try await recreatedClient.accessTokenForAuthorizedRequest() XCTAssertEqual(recoveredToken, replacement.accessToken) let storedReplacement = await store.session XCTAssertEqual(storedReplacement, replacement) let requests = await transport.requests let refreshRequests = requests.filter { $0.url?.path == "/v1/auth/refresh" } XCTAssertEqual(refreshRequests.count, 2) let firstOperationID = try refreshOperationID(from: refreshRequests[0]) let retryOperationID = try refreshOperationID(from: refreshRequests[1]) XCTAssertEqual(firstOperationID, retryOperationID) XCTAssertEqual(firstOperationID, retainedTransaction?.operationId) } func testConcurrentUnauthorizedRequestsMergeRefreshRotation() async throws { let old = makeAccountSession() let replacement = makeAccountSession( accessToken: "access-new", refreshToken: "refresh-new" ) let transport = RefreshMergingTransport(replacementSession: replacement) let store = InMemoryAccountSecurityStore(session: old) let client = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store ) async let first = client.account() async let second = client.account() let (firstAccount, secondAccount) = try await (first, second) let accounts = [firstAccount, secondAccount] XCTAssertEqual( accounts, [ OSGAccount(id: old.accountId, createdAtEpochSeconds: 1), OSGAccount(id: old.accountId, createdAtEpochSeconds: 1) ] ) let refreshCount = await transport.refreshCount XCTAssertEqual(refreshCount, 1) let stored = await store.session XCTAssertEqual(stored, replacement) } func testConcurrentClientInstancesSharePersistedRefreshOperationID() async throws { let old = makeAccountSession() let replacement = makeAccountSession( accessToken: "access-shared", refreshToken: "refresh-shared" ) let transport = RefreshMergingTransport(replacementSession: replacement) let store = InMemoryAccountSecurityStore(session: old) let firstClient = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store ) let secondClient = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store ) async let first = firstClient.account() async let second = secondClient.account() _ = try await (first, second) let requests = await transport.requests let refreshRequests = requests.filter { $0.url?.path == "/v1/auth/refresh" } XCTAssertEqual(refreshRequests.count, 2) let operationIDs = try refreshRequests.map { try refreshOperationID(from: $0) } XCTAssertEqual(Set(operationIDs).count, 1) let stored = await store.session XCTAssertEqual(stored, replacement) } func testRefreshTokenReuseClearsPrivateSession() async throws { let old = makeAccountSession() let replacement = makeAccountSession(accessToken: "unused", refreshToken: "unused") let transport = RefreshMergingTransport( replacementSession: replacement, refreshError: .init( statusCode: 401, body: apiErrorData( code: "refresh_token_reuse", message: "family revoked" ) ) ) let store = InMemoryAccountSecurityStore(session: old) let client = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store ) do { _ = try await client.account() XCTFail("Expected refresh-token reuse") } catch let error as AccountAPIError { XCTAssertEqual(error, .refreshTokenReuse) } let stored = await store.session let clearCount = await store.clearSessionCount let refreshTransaction = await store.refreshTransaction XCTAssertNil(stored) XCTAssertEqual(clearCount, 1) XCTAssertNil(refreshTransaction) } func testLogoutClearsPrivateSessionWhenRevocationIsUnavailable() async throws { let store = InMemoryAccountSecurityStore( session: makeAccountSession(), refreshTransaction: AccountRefreshTransaction( refreshTokenDigest: "pending", operationId: UUID() ) ) let transport = QueueAccountTransport([]) let client = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store ) try await client.logout() let stored = await store.session let clearCount = await store.clearSessionCount let refreshTransaction = await store.refreshTransaction let requests = await transport.requests XCTAssertNil(stored) XCTAssertEqual(clearCount, 1) XCTAssertNil(refreshTransaction) XCTAssertEqual(requests.single?.url?.path, "/v1/auth/logout") } func testDeleteAccountUsesAuthenticatedDeleteAndClearsPrivateSession() async throws { let store = InMemoryAccountSecurityStore(session: makeAccountSession()) let transport = QueueAccountTransport([ .init(statusCode: 204, body: Data()) ]) let client = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store ) try await client.deleteAccount( with: DeleteAccountRequest( identityToken: "identity", authorizationCode: "authorization", nonce: "nonce" ) ) let requests = await transport.requests let stored = await store.session XCTAssertEqual(requests.single?.httpMethod, "DELETE") XCTAssertEqual(requests.single?.url?.path, "/v1/account") XCTAssertEqual( requests.single?.value(forHTTPHeaderField: "Authorization"), "Bearer access-old" ) XCTAssertNil(stored) } func testStableServerErrorCodeMapsToTypedError() async throws { let transport = QueueAccountTransport([ .init( statusCode: 503, body: apiErrorData( code: "external_service_unavailable", message: "Apple is temporarily unavailable" ) ) ]) let store = InMemoryAccountSecurityStore() let client = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store ) do { _ = try await client.signInWithApple( AppleSignInRequest( identityToken: "identity", authorizationCode: "code", nonce: "nonce", deviceCheckToken: nil, appAttest: nil ) ) XCTFail("Expected mapped server error") } catch let error as AccountAPIError { XCTAssertEqual( error, .externalServiceUnavailable("Apple is temporarily unavailable") ) } } func testStoreKitSubmissionUsesOnlyTheClosedAuthorizedEndpoint() async throws { let transport = QueueAccountTransport([ .init(statusCode: 200, body: Data("{}".utf8)) ]) let store = InMemoryAccountSecurityStore(session: makeAccountSession()) let client = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store ) let body = Data(#"{"signedTransaction":"header.payload.signature"}"#.utf8) _ = try await client.authorizedResourceData( .submitStoreKitTransaction, body: body ) let requests = await transport.requests let request = try XCTUnwrap(requests.single) XCTAssertEqual(request.url?.path, "/v1/storekit/transactions") XCTAssertEqual(request.httpMethod, "POST") XCTAssertEqual(request.httpBody, body) XCTAssertEqual( request.value(forHTTPHeaderField: "Authorization"), "Bearer access-old" ) } func testStoreKitHistoryUsesAuthenticatedBoundedCursorRequest() async throws { let transport = QueueAccountTransport([ .init(statusCode: 200, body: Data(#"{"items":[],"nextCursor":null}"#.utf8)) ]) let store = InMemoryAccountSecurityStore(session: makeAccountSession()) let client = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store ) _ = try await client.authorizedResourceData( .storeKitTransactions(limit: 500, cursor: "page_1-token") ) let requests = await transport.requests let request = try XCTUnwrap(requests.single) let components = try XCTUnwrap( URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false) ) let query = Dictionary( uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in item.value.map { (item.name, $0) } } ) XCTAssertEqual(request.url?.path, "/v1/storekit/transactions") XCTAssertEqual(request.httpMethod, "GET") XCTAssertEqual(query, ["limit": "100", "cursor": "page_1-token"]) XCTAssertEqual( request.value(forHTTPHeaderField: "Authorization"), "Bearer access-old" ) } func testCreditBalanceRequestBypassesLocalURLCache() async throws { let transport = QueueAccountTransport([ .init(statusCode: 200, body: Data("{}".utf8)) ]) let store = InMemoryAccountSecurityStore(session: makeAccountSession()) let client = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store ) _ = try await client.authorizedResourceData(.creditsBalance) let requests = await transport.requests let request = try XCTUnwrap(requests.single) XCTAssertEqual(request.cachePolicy, .reloadIgnoringLocalCacheData) XCTAssertEqual( request.value(forHTTPHeaderField: "Cache-Control"), "no-cache" ) } func testTransientCreditBalanceFailureRetriesOnce() async throws { let transport = QueueAccountTransport([ .init( statusCode: 503, body: apiErrorData( code: "external_service_unavailable", message: "temporarily unavailable" ) ), .init(statusCode: 200, body: Data(#"{"balance":500}"#.utf8)) ]) let store = InMemoryAccountSecurityStore(session: makeAccountSession()) let client = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store ) let data = try await client.authorizedResourceData(.creditsBalance) XCTAssertEqual(data, Data(#"{"balance":500}"#.utf8)) let requests = await transport.requests XCTAssertEqual(requests.count, 2) XCTAssertTrue(requests.allSatisfy { $0.url?.path == "/v1/credits/balance" }) } func testTransientAccountFailureRetriesOnce() async throws { let accountID = UUID() let transport = QueueAccountTransport([ .init( statusCode: 503, body: apiErrorData( code: "external_service_unavailable", message: "temporarily unavailable" ) ), .init( statusCode: 200, body: Data( """ {"data":{"id":"\(accountID.uuidString)","createdAtEpochSeconds":1}} """.utf8 ) ) ]) let store = InMemoryAccountSecurityStore(session: makeAccountSession()) let client = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store ) let account = try await client.account() XCTAssertEqual(account.id, accountID) let requests = await transport.requests XCTAssertEqual(requests.count, 2) XCTAssertTrue(requests.allSatisfy { $0.url?.path == "/v1/account" }) } private func refreshOperationID(from request: URLRequest) throws -> UUID { let body = try XCTUnwrap(request.httpBody) let json = try XCTUnwrap( JSONSerialization.jsonObject(with: body) as? [String: Any] ) XCTAssertEqual(json["refreshToken"] as? String, "refresh-old") let rawValue = try XCTUnwrap(json["refreshOperationId"] as? String) return try XCTUnwrap(UUID(uuidString: rawValue)) } } private extension Array { var single: Element? { count == 1 ? first : nil } }