feat(app): add flow diagnostics and refine interface
Improve failure recovery visibility, strengthen account-session handling, and make the cross-platform interface more consistent.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user