fix(account): merge durable session refresh
CI / Validate manifests (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
CI / iOS / Extension (push) Has been cancelled
CI / macOS (push) Has been cancelled

Preserve refresh idempotency and Apple credential revocation handling alongside the consolidated app updates.
This commit is contained in:
Rocky
2026-08-25 16:44:51 +08:00
16 changed files with 438 additions and 33 deletions
+127 -3
View File
@@ -13,7 +13,12 @@ final class AccountAPIClientTests: XCTestCase {
let transport = QueueAccountTransport([
.init(statusCode: 200, body: try sessionEnvelopeData(expected))
])
let store = InMemoryAccountSecurityStore()
let store = InMemoryAccountSecurityStore(
refreshTransaction: AccountRefreshTransaction(
refreshTokenDigest: "stale",
operationId: UUID()
)
)
let client = AccountAPIClient(
baseURL: URL(string: "https://account.test")!,
transport: transport,
@@ -32,7 +37,9 @@ final class AccountAPIClientTests: XCTestCase {
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"))
@@ -156,6 +163,10 @@ final class AccountAPIClientTests: XCTestCase {
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)
@@ -191,7 +202,66 @@ final class AccountAPIClientTests: XCTestCase {
XCTAssertEqual(token, "access-fresh")
let requests = await transport.requests
XCTAssertEqual(requests.single?.url?.path, "/v1/auth/refresh")
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 testRecreatedClientRestoresRetainedSessionAndRefreshesIt() async throws {
@@ -271,6 +341,40 @@ final class AccountAPIClientTests: XCTestCase {
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")
@@ -300,12 +404,20 @@ final class AccountAPIClientTests: XCTestCase {
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())
let store = InMemoryAccountSecurityStore(
session: makeAccountSession(),
refreshTransaction: AccountRefreshTransaction(
refreshTokenDigest: "pending",
operationId: UUID()
)
)
let transport = QueueAccountTransport([])
let client = AccountAPIClient(
baseURL: URL(string: "https://account.test")!,
@@ -317,9 +429,11 @@ final class AccountAPIClientTests: XCTestCase {
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")
}
@@ -532,6 +646,16 @@ final class AccountAPIClientTests: XCTestCase {
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 {
@@ -540,6 +540,36 @@ final class AccountCenterViewModelTests: XCTestCase {
XCTAssertEqual(managedGatewayClearCount, 1)
}
@MainActor
func testRevokedAppleCredentialClearsSignedInAccountState() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
appleCredentialState: .revoked
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore()
)
await coordinator.restoreIfNeeded()
XCTAssertTrue(coordinator.isSignedIn)
await coordinator.validateAppleCredentialState()
XCTAssertEqual(coordinator.sessionPhase, .signedOut)
XCTAssertEqual(coordinator.snapshotPhase, .idle)
XCTAssertEqual(coordinator.operationErrorKey, "account.error.sessionExpired")
let signOutCount = await service.signOutCount()
XCTAssertEqual(signOutCount, 1)
}
private func makeReferral(status: AccountReferralStatus) -> AccountReferral {
AccountReferral(
id: UUID(),
@@ -616,6 +646,7 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
private let signInDelayNanoseconds: UInt64
private let accountLoadDelayNanoseconds: UInt64
private var remainingRestoreFailures: Int
private let storedAppleCredentialState: AccountAppleCredentialState
private var redeemed: [String] = []
private var centerLoadCount = 0
private var logoutCount = 0
@@ -634,7 +665,8 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
signOutDelayNanoseconds: UInt64 = 0,
signInDelayNanoseconds: UInt64 = 0,
restoreFailureCount: Int = 0,
accountLoadDelayNanoseconds: UInt64 = 0
accountLoadDelayNanoseconds: UInt64 = 0,
appleCredentialState: AccountAppleCredentialState = .unknown
) {
restored = restoredSession
signedInAccount = signInSession ?? restoredSession
@@ -646,6 +678,7 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
self.signInDelayNanoseconds = signInDelayNanoseconds
remainingRestoreFailures = restoreFailureCount
self.accountLoadDelayNanoseconds = accountLoadDelayNanoseconds
self.storedAppleCredentialState = appleCredentialState
}
func restoreSession() async throws -> AccountSession? {
@@ -676,6 +709,10 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
accountDeleteCount += 1
}
func appleCredentialState() async -> AccountAppleCredentialState {
storedAppleCredentialState
}
func clearManagedGateway() async {
gatewayClearCount += 1
}
@@ -8,12 +8,22 @@ import Foundation
actor InMemoryAccountSecurityStore: AccountSessionVault, AppAttestKeyStateStoring {
private(set) var session: AccountSession?
private(set) var refreshTransaction: AccountRefreshTransaction?
private(set) var keyState: AppAttestKeyState?
private(set) var clearSessionCount = 0
private(set) var clearRefreshTransactionCount = 0
private var remainingSessionSaveFailures: Int
init(session: AccountSession? = nil, keyState: AppAttestKeyState? = nil) {
init(
session: AccountSession? = nil,
refreshTransaction: AccountRefreshTransaction? = nil,
keyState: AppAttestKeyState? = nil,
sessionSaveFailures: Int = 0
) {
self.session = session
self.refreshTransaction = refreshTransaction
self.keyState = keyState
self.remainingSessionSaveFailures = sessionSaveFailures
}
func loadSession() async throws -> AccountSession? {
@@ -21,6 +31,10 @@ actor InMemoryAccountSecurityStore: AccountSessionVault, AppAttestKeyStateStorin
}
func saveSession(_ session: AccountSession) async throws {
if remainingSessionSaveFailures > 0 {
remainingSessionSaveFailures -= 1
throw AccountAPIError.secureStorage
}
self.session = session
}
@@ -29,6 +43,26 @@ actor InMemoryAccountSecurityStore: AccountSessionVault, AppAttestKeyStateStorin
clearSessionCount += 1
}
func beginRefreshTransaction(
refreshTokenDigest: String
) async throws -> AccountRefreshTransaction {
if let refreshTransaction,
refreshTransaction.refreshTokenDigest == refreshTokenDigest {
return refreshTransaction
}
let created = AccountRefreshTransaction(
refreshTokenDigest: refreshTokenDigest,
operationId: UUID()
)
refreshTransaction = created
return created
}
func clearRefreshTransaction() async throws {
refreshTransaction = nil
clearRefreshTransactionCount += 1
}
func loadAppAttestKeyState() async throws -> AppAttestKeyState? {
keyState
}