Files
OSGKeyboard/OSGKeyboardTests/AccountAPIClientTests.swift
T
Rocky 498f407585 feat(account): add managed credits and cloud gateway
Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
2026-08-20 11:43:21 +08:00

431 lines
16 KiB
Swift

// AccountAPIClientTests.swift
// OSGKeyboardTests
//
// Hermetic tests for session rotation, retry limits, and stable API errors.
@testable import OSGKeyboardHostSupport
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()
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
XCTAssertEqual(stored, expected)
let requests = await transport.requests
XCTAssertEqual(requests.single?.url?.path, "/v1/auth/apple")
XCTAssertNil(requests.single?.value(forHTTPHeaderField: "Authorization"))
}
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
)
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 stored = await store.session
let clearCount = await store.clearSessionCount
XCTAssertNil(stored)
XCTAssertEqual(clearCount, 1)
}
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
XCTAssertEqual(requests.single?.url?.path, "/v1/auth/refresh")
}
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 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
XCTAssertNil(stored)
XCTAssertEqual(clearCount, 1)
}
func testLogoutClearsPrivateSessionWhenRevocationIsUnavailable() async throws {
let store = InMemoryAccountSecurityStore(session: makeAccountSession())
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 requests = await transport.requests
XCTAssertNil(stored)
XCTAssertEqual(clearCount, 1)
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 extension Array {
var single: Element? {
count == 1 ? first : nil
}
}