feat(app): add flow diagnostics and refine interface
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

Improve failure recovery visibility, strengthen account-session handling, and make the cross-platform interface more consistent.
This commit is contained in:
Rocky
2026-08-26 14:40:06 +08:00
parent 1ee032bdb9
commit 39002336c0
78 changed files with 2737 additions and 735 deletions
@@ -437,6 +437,37 @@ final class AccountAPIClientTests: XCTestCase {
XCTAssertEqual(requests.single?.url?.path, "/v1/auth/logout")
}
func testLogoutClearFailureRetainsSessionForRetry() async throws {
let retained = makeAccountSession()
let store = InMemoryAccountSecurityStore(
session: retained,
sessionClearFailures: 1
)
let transport = QueueAccountTransport([])
let client = AccountAPIClient(
baseURL: URL(string: "https://account.test")!,
transport: transport,
sessionVault: store
)
do {
try await client.logout()
XCTFail("Expected the first Keychain deletion to fail")
} catch let error as AccountAPIError {
XCTAssertEqual(error, .secureStorage)
}
let sessionAfterFailure = await store.session
let cachedSessionAfterFailure = try await client.currentSession()
XCTAssertEqual(sessionAfterFailure, retained)
XCTAssertEqual(cachedSessionAfterFailure, retained)
try await client.logout()
let sessionAfterRetry = await store.session
XCTAssertNil(sessionAfterRetry)
}
func testDeleteAccountUsesAuthenticatedDeleteAndClearsPrivateSession() async throws {
let store = InMemoryAccountSecurityStore(session: makeAccountSession())
let transport = QueueAccountTransport([
@@ -3,6 +3,7 @@
//
// Pure invitation parsing and account coordinator state-machine coverage.
import AuthenticationServices
@testable import OSGKeyboard
import XCTest
@@ -184,7 +185,8 @@ final class AccountCenterViewModelTests: XCTestCase {
with: AppleAuthorizationPayload(
identityToken: "identity",
authorizationCode: "authorization",
nonce: "nonce"
nonce: "nonce",
userIdentifier: "apple-user"
)
)
}
@@ -341,6 +343,40 @@ final class AccountCenterViewModelTests: XCTestCase {
XCTAssertEqual(loadCount, 2)
}
@MainActor
func testForcedRefreshQueuedDuringInFlightRefreshRunsAfterward() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let clock = MutableAccountClock(now: Date(timeIntervalSince1970: 1_000))
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
accountLoadDelayNanoseconds: 20_000_000
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore(),
now: { clock.now }
)
await coordinator.restoreIfNeeded()
clock.now = clock.now.addingTimeInterval(601)
let staleRefresh = Task { await coordinator.refreshAccountData() }
while !coordinator.isRefreshingAccountData {
await Task.yield()
}
await coordinator.refreshAccountData(force: true)
await staleRefresh.value
let loadCount = await service.loadCount()
XCTAssertEqual(loadCount, 3)
}
@MainActor
func testFailedAccountRefreshKeepsLastSuccessfulSnapshotAndTime() async {
let account = AccountSession(
@@ -488,7 +524,8 @@ final class AccountCenterViewModelTests: XCTestCase {
with: AppleAuthorizationPayload(
identityToken: "identity",
authorizationCode: "authorization",
nonce: "nonce"
nonce: "nonce",
userIdentifier: "apple-user"
)
)
@@ -570,6 +607,128 @@ final class AccountCenterViewModelTests: XCTestCase {
XCTAssertEqual(signOutCount, 1)
}
@MainActor
func testMissingAppleIdentifierRequiresOneTimeReauthentication() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
appleCredentialState: .reauthenticationRequired
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore()
)
await coordinator.restoreIfNeeded()
await coordinator.validateAppleCredentialState()
XCTAssertEqual(coordinator.sessionPhase, .signedOut)
XCTAssertEqual(
coordinator.operationErrorKey,
"account.error.appleReauthenticationRequired"
)
}
@MainActor
func testRevocationSignOutFailureKeepsSessionForRetry() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
shouldFailSignOut: true,
appleCredentialState: .revoked
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore()
)
await coordinator.restoreIfNeeded()
await coordinator.validateAppleCredentialState()
XCTAssertEqual(coordinator.sessionPhase, .signedIn(account))
XCTAssertEqual(coordinator.operationErrorKey, "account.error.signOut")
}
@MainActor
func testCredentialRevocationNotificationClearsSignedInState() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let center = NotificationCenter()
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account)
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore(),
notificationCenter: center
)
await coordinator.restoreIfNeeded()
center.post(
name: ASAuthorizationAppleIDProvider.credentialRevokedNotification,
object: nil
)
for _ in 0..<100 where coordinator.isSignedIn {
await Task.yield()
}
XCTAssertEqual(coordinator.sessionPhase, .signedOut)
XCTAssertEqual(coordinator.operationErrorKey, "account.error.sessionExpired")
}
@MainActor
func testRevocationPreventsInFlightProfileUpdateFromRestoringSession() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000,
displayName: "Before"
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
profileUpdateDelayNanoseconds: 20_000_000,
appleCredentialState: .revoked
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore()
)
await coordinator.restoreIfNeeded()
let update = Task { await coordinator.updateDisplayName("After") }
while coordinator.operation != .updatingProfile {
await Task.yield()
}
await coordinator.validateAppleCredentialState()
let didUpdate = await update.value
XCTAssertFalse(didUpdate)
XCTAssertEqual(coordinator.sessionPhase, .signedOut)
}
private func makeReferral(status: AccountReferralStatus) -> AccountReferral {
AccountReferral(
id: UUID(),
@@ -642,9 +801,11 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
private let refreshedSnapshot: AccountCenterSnapshot?
private let shouldFailRedemption: Bool
private let shouldFailAccountRefresh: Bool
private let shouldFailSignOut: Bool
private let signOutDelayNanoseconds: UInt64
private let signInDelayNanoseconds: UInt64
private let accountLoadDelayNanoseconds: UInt64
private let profileUpdateDelayNanoseconds: UInt64
private var remainingRestoreFailures: Int
private let storedAppleCredentialState: AccountAppleCredentialState
private var redeemed: [String] = []
@@ -662,10 +823,12 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
refreshedSnapshot: AccountCenterSnapshot? = nil,
shouldFailRedemption: Bool = false,
shouldFailAccountRefresh: Bool = false,
shouldFailSignOut: Bool = false,
signOutDelayNanoseconds: UInt64 = 0,
signInDelayNanoseconds: UInt64 = 0,
restoreFailureCount: Int = 0,
accountLoadDelayNanoseconds: UInt64 = 0,
profileUpdateDelayNanoseconds: UInt64 = 0,
appleCredentialState: AccountAppleCredentialState = .unknown
) {
restored = restoredSession
@@ -674,10 +837,12 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
self.refreshedSnapshot = refreshedSnapshot
self.shouldFailRedemption = shouldFailRedemption
self.shouldFailAccountRefresh = shouldFailAccountRefresh
self.shouldFailSignOut = shouldFailSignOut
self.signOutDelayNanoseconds = signOutDelayNanoseconds
self.signInDelayNanoseconds = signInDelayNanoseconds
remainingRestoreFailures = restoreFailureCount
self.accountLoadDelayNanoseconds = accountLoadDelayNanoseconds
self.profileUpdateDelayNanoseconds = profileUpdateDelayNanoseconds
self.storedAppleCredentialState = appleCredentialState
}
@@ -703,6 +868,9 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
try await Task.sleep(nanoseconds: signOutDelayNanoseconds)
}
logoutCount += 1
if shouldFailSignOut {
throw AccountServiceSpyError.failed
}
}
func deleteAccount(with payload: AppleAuthorizationPayload) async throws {
@@ -739,6 +907,20 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
return centerSnapshot
}
func updateDisplayName(_ displayName: String) async throws -> AccountSession {
if profileUpdateDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: profileUpdateDelayNanoseconds)
}
guard let signedInAccount else {
throw AccountIntegrationError.unavailable
}
return AccountSession(
accountID: signedInAccount.accountID,
createdAtEpochSeconds: signedInAccount.createdAtEpochSeconds,
displayName: displayName
)
}
func redeemReferral(code: String) async throws {
if shouldFailRedemption {
throw AccountServiceSpyError.failed
@@ -0,0 +1,126 @@
// FlowFailureLogStoreTests.swift
// OSGKeyboardTests
import Foundation
@testable import OSGKeyboard
import XCTest
final class FlowFailureLogStoreTests: XCTestCase {
func testFailureReportKeepsOnlyTenSecondsAndRedactsSensitiveMetadata() throws {
let directory = temporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let clock = MutableFailureLogClock(
Date(timeIntervalSince1970: 1_800_000_000)
)
let store = FlowFailureLogStore(
directoryURL: directory,
now: { clock.current }
)
store.record("event=tooOld")
clock.advance(by: 11)
store.record(
"event=current sessionId=550e8400-e29b-41d4-a716-446655440000 "
+ "access_token=secret container=/private/var/mobile/Containers/example"
)
let url = try XCTUnwrap(
store.persistStartupFailure(
reason: "timedOut",
context: ["authorization": "bearer=secret"]
)
)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let report = try decoder.decode(
FlowStartupFailureReport.self,
from: Data(contentsOf: url)
)
XCTAssertEqual(report.reason, "timedOut")
XCTAssertEqual(report.events.count, 1)
XCTAssertFalse(report.events[0].message.contains("tooOld"))
XCTAssertFalse(report.events[0].message.contains("550e8400"))
XCTAssertFalse(report.events[0].message.contains("secret"))
XCTAssertFalse(report.events[0].message.contains("/private/var/mobile"))
XCTAssertEqual(report.events[0].message.components(separatedBy: "<uuid>").count, 2)
}
func testReportRetentionKeepsNewestFilesWithinCountLimit() throws {
let directory = temporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let clock = MutableFailureLogClock(
Date(timeIntervalSince1970: 1_800_000_000)
)
let store = FlowFailureLogStore(
directoryURL: directory,
maxReportCount: 2,
now: { clock.current }
)
for index in 0..<3 {
store.record("event=failure\(index)")
XCTAssertNotNil(
store.persistStartupFailure(
reason: "timedOut",
context: ["index": "\(index)"]
)
)
clock.advance(by: 1)
}
XCTAssertEqual(store.reportURLs().count, 2)
}
func testExportCombinesReportsAndClearRemovesAllFiles() throws {
let directory = temporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let store = FlowFailureLogStore(directoryURL: directory)
store.record("event=failure")
XCTAssertNotNil(
store.persistStartupFailure(
reason: "notPossible",
context: [:]
)
)
let exportURL = try XCTUnwrap(store.makeExportURL())
let exportText = try String(contentsOf: exportURL, encoding: .utf8)
XCTAssertTrue(exportText.contains("\"reports\""))
XCTAssertTrue(exportText.contains("\"notPossible\""))
store.deleteAllReports()
XCTAssertTrue(store.reportURLs().isEmpty)
XCTAssertFalse(FileManager.default.fileExists(atPath: exportURL.path))
}
private func temporaryDirectory() -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent(
"FlowFailureLogStoreTests-\(UUID().uuidString)",
isDirectory: true
)
}
}
private final class MutableFailureLogClock: @unchecked Sendable {
private let lock: NSLock
private var value: Date
init(_ date: Date) {
value = date
lock = NSLock()
}
var current: Date {
lock.withLock { value }
}
func advance(by interval: TimeInterval) {
lock.withLock {
value = value.addingTimeInterval(interval)
}
}
}
+2 -1
View File
@@ -314,7 +314,8 @@ final class ReferralProfileTests: XCTestCase {
with: AppleAuthorizationPayload(
identityToken: "identity",
authorizationCode: "authorization",
nonce: "nonce"
nonce: "nonce",
userIdentifier: "apple-user"
)
)
@@ -13,17 +13,20 @@ actor InMemoryAccountSecurityStore: AccountSessionVault, AppAttestKeyStateStorin
private(set) var clearSessionCount = 0
private(set) var clearRefreshTransactionCount = 0
private var remainingSessionSaveFailures: Int
private var remainingSessionClearFailures: Int
init(
session: AccountSession? = nil,
refreshTransaction: AccountRefreshTransaction? = nil,
keyState: AppAttestKeyState? = nil,
sessionSaveFailures: Int = 0
sessionSaveFailures: Int = 0,
sessionClearFailures: Int = 0
) {
self.session = session
self.refreshTransaction = refreshTransaction
self.keyState = keyState
self.remainingSessionSaveFailures = sessionSaveFailures
self.remainingSessionClearFailures = sessionClearFailures
}
func loadSession() async throws -> AccountSession? {
@@ -39,6 +42,10 @@ actor InMemoryAccountSecurityStore: AccountSessionVault, AppAttestKeyStateStorin
}
func clearSession() async throws {
if remainingSessionClearFailures > 0 {
remainingSessionClearFailures -= 1
throw AccountAPIError.secureStorage
}
session = nil
clearSessionCount += 1
}