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.
This commit is contained in:
Rocky
2026-08-20 11:43:21 +08:00
parent 0f9280bd00
commit 498f407585
301 changed files with 19221 additions and 10891 deletions
@@ -0,0 +1,430 @@
// 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
}
}