Add timeouts after verifying the device owner when linking a new device. (#5970)

* Support for waitingForAuth continuation sender during Link new device

* Stop automatically regenerating new QR codes when they expire.

This reverts part of commit e235e3ba81.

* Add a timeout after the Face/TouchID verification when linking a desktop computer.

---------

Co-authored-by: Hugh Nimmo-Smith <hughns@element.io>
This commit is contained in:
Doug
2026-08-06 14:56:34 +01:00
committed by GitHub
parent a0521d1fdf
commit a82726a991
11 changed files with 125 additions and 112 deletions
@@ -57,7 +57,7 @@ class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
switch action { switch action {
case .linkMobileDevice(let progressPublisher): case .linkMobileDevice(let progressPublisher):
presentQRCodeScreen(mode: .linkMobile(progressPublisher, flowParameters.userSession.clientProxy)) presentQRCodeScreen(mode: .linkMobile(progressPublisher))
case .linkDesktopComputer: case .linkDesktopComputer:
presentQRCodeScreen(mode: .linkDesktop(flowParameters.userSession.clientProxy.linkNewDeviceService())) presentQRCodeScreen(mode: .linkDesktop(flowParameters.userSession.clientProxy.linkNewDeviceService()))
case .verifyWithAppLockPIN(let continuation): case .verifyWithAppLockPIN(let continuation):
@@ -39,7 +39,7 @@ enum QRCodeLoginScreenMode {
/// Configures the screen to link another device by scanning a QR code. /// Configures the screen to link another device by scanning a QR code.
case linkDesktop(LinkNewDeviceServiceProtocol) case linkDesktop(LinkNewDeviceServiceProtocol)
/// Configures the screen to link another device by showing it a QR code. /// Configures the screen to link another device by showing it a QR code.
case linkMobile(LinkNewDeviceService.LinkMobileProgressPublisher, ClientProxyProtocol) case linkMobile(LinkNewDeviceService.LinkMobileProgressPublisher)
} }
struct QRCodeLoginScreenViewState: BindableState { struct QRCodeLoginScreenViewState: BindableState {
@@ -107,7 +107,7 @@ nonisolated enum QRCodeLoginState: Equatable {
case displayCode(DisplayCodeState) case displayCode(DisplayCodeState)
/// Initial state where the user can link another device using the shown QR code. /// Initial state where the user can link another device using the shown QR code.
case displayQR(DisplayQRState) case displayQR(UIImage)
/// The user needs to enter the two digit code to confirm the channel is secure /// The user needs to enter the two digit code to confirm the channel is secure
case confirmCode(CheckCodeState) case confirmCode(CheckCodeState)
@@ -171,13 +171,6 @@ nonisolated enum QRCodeLoginState: Equatable {
} }
} }
nonisolated enum DisplayQRState: Equatable {
/// The QR code is available and is being shown.
case active(UIImage)
/// The QR code being shown has expired. We will need to generate a new one.
case expired
}
nonisolated enum DisplayCodeState: Equatable { nonisolated enum DisplayCodeState: Equatable {
case deviceCode(String) case deviceCode(String)
case verificationCode(String) case verificationCode(String)
@@ -13,6 +13,11 @@ typealias QRCodeLoginScreenViewModelType = StateStoreViewModel<QRCodeLoginScreen
class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScreenViewModelProtocol { class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScreenViewModelProtocol {
private let appMediator: AppMediatorProtocol private let appMediator: AppMediatorProtocol
/// The time allowed between verifying the device's owner (on the previous screen), to having
/// scanned a QR code and established a secure channel for the `linkDesktopComputer` flow.
///
/// The generated QR code handles this automatically when linking a mobile device and QR login doesn't need it.
private let linkDesktopTimeout: Duration
private let actionsSubject: PassthroughSubject<QRCodeLoginScreenViewModelAction, Never> = .init() private let actionsSubject: PassthroughSubject<QRCodeLoginScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<QRCodeLoginScreenViewModelAction, Never> { var actionsPublisher: AnyPublisher<QRCodeLoginScreenViewModelAction, Never> {
@@ -21,18 +26,21 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
private var currentTask: AnyCancellable? private var currentTask: AnyCancellable?
private var oAuthResultTask: AnyCancellable? private var oAuthResultTask: AnyCancellable?
private var expirationTask: AnyCancellable?
init(mode: QRCodeLoginScreenMode, init(mode: QRCodeLoginScreenMode,
canSignInManually: Bool, canSignInManually: Bool,
appMediator: AppMediatorProtocol) { appMediator: AppMediatorProtocol,
linkDesktopTimeout: Duration = .seconds(120)) {
self.appMediator = appMediator self.appMediator = appMediator
self.linkDesktopTimeout = linkDesktopTimeout
let initialViewState: QRCodeLoginState = switch mode { let initialViewState: QRCodeLoginState = switch mode {
case .login: .loginInstructions case .login: .loginInstructions
case .linkDesktop: .linkDesktopInstructions case .linkDesktop: .linkDesktopInstructions
case .linkMobile(let progressPublisher, _): case .linkMobile(let progressPublisher):
switch progressPublisher.value { switch progressPublisher.value {
case .qrReady(let image): .displayQR(.active(image)) case .qrReady(let image): .displayQR(image)
default: .error(.unknown) default: .error(.unknown)
} }
} }
@@ -40,8 +48,13 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
super.init(initialViewState: .init(state: initialViewState, mode: mode, canSignInManually: canSignInManually)) super.init(initialViewState: .init(state: initialViewState, mode: mode, canSignInManually: canSignInManually))
setupSubscriptions() setupSubscriptions()
if case .linkMobile(let progressPublisher, _) = mode { switch mode {
case .linkDesktop:
scheduleLinkDesktopExpiration()
case .linkMobile(let progressPublisher):
listenToDisplayQRProgress(progressPublisher: progressPublisher) listenToDisplayQRProgress(progressPublisher: progressPublisher)
case .login:
break
} }
} }
@@ -180,9 +193,10 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
case .starting: case .starting:
break // Nothing to do, the state was set above. break // Nothing to do, the state was set above.
case .establishingSecureChannel(let checkCodeString): case .establishingSecureChannel(let checkCodeString):
expirationTask?.cancel()
state.state = .displayCode(.deviceCode(checkCodeString)) state.state = .displayCode(.deviceCode(checkCodeString))
case .waitingForAuthorisation(let url): case .waitingForAuthorisation(let verificationURL, let continuationSender):
requestOAuthAuthorization(url: url) requestOAuthAuthorization(url: verificationURL, continuationSender: continuationSender)
case .syncingSecrets: case .syncingSecrets:
break // Nothing to do. break // Nothing to do.
case .done: case .done:
@@ -221,8 +235,8 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
break // Nothing to do, we are already showing the code by the time this method is called. break // Nothing to do, we are already showing the code by the time this method is called.
case .qrScanned(let checkCodeSender): case .qrScanned(let checkCodeSender):
state.state = .confirmCode(.inputCode(checkCodeSender)) state.state = .confirmCode(.inputCode(checkCodeSender))
case .waitingForAuthorisation(let url): case .waitingForAuthorisation(let url, let continuationSender):
requestOAuthAuthorization(url: url) requestOAuthAuthorization(url: url, continuationSender: continuationSender)
case .syncingSecrets: case .syncingSecrets:
break // Nothing to do. break // Nothing to do.
case .done: case .done:
@@ -232,37 +246,29 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
} }
} }
private func regenerateQRCode() async { /// Starts the timeout for scanning a desktop's QR code. The flow will expire (cancelling the scan)
guard case .linkMobile(_, let clientProxy) = state.mode else { /// if the secure channel isn't established within the timeout period.
fatalError("Cannot display a QR code in this mode.") private func scheduleLinkDesktopExpiration() {
guard case .linkDesktop = state.mode else { fatalError("Expiration requested on an unexpected flow") }
expirationTask = Task { [weak self, linkDesktopTimeout] in
try? await Task.sleep(for: linkDesktopTimeout, tolerance: .zero)
guard !Task.isCancelled else { return }
self?.handleExpiration()
}
.asCancellable()
}
private func handleExpiration() {
// Don't override an error that's already being shown.
if case .error = state.state {
currentTask = nil // Cancel the scan (just in case).
return
} }
let linkNewDeviceService = clientProxy.linkNewDeviceService() MXLog.info("Timed out establishing a secure channel, expiring.")
let progressPublisher = linkNewDeviceService.linkMobileDevice() currentTask = nil // Cancel the scan.
handleError(.expired)
do {
async let minimumDelay = Task.sleep(for: .seconds(1)) // To show the spinner for long enough so there isn't a flicker.
async let qrReadyProgress = progressPublisher.values
.first { progress in
switch progress {
case .qrReady: true
default: false
}
}
guard case .qrReady(let image) = try await (qrReadyProgress, minimumDelay).0 else {
state.state = .error(.unknown)
return
}
state.state = .displayQR(.active(image))
} catch let error as QRCodeLoginError {
handleError(error)
} catch {
handleError(.unknown)
}
listenToDisplayQRProgress(progressPublisher: progressPublisher)
} }
private func sendCheckCode() async { private func sendCheckCode() async {
@@ -284,7 +290,11 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
} }
} }
private func requestOAuthAuthorization(url: URL) { private func requestOAuthAuthorization(url: URL, continuationSender: ContinuationMessageSenderProxy) {
// There's no OAuth redirect, so the WAS always returns a cancellation failure (both for a declined system prompt and
// when closing after successful authorisation). We can't use the continuation as intended, so confirm it and continue.
Task { await continuationSender.confirm() }
let (stream, continuation) = AsyncStream<Result<Void, OAuthError>>.makeStream() let (stream, continuation) = AsyncStream<Result<Void, OAuthError>>.makeStream()
actionsSubject.send(.requestOAuthAuthorisation(url, continuation)) actionsSubject.send(.requestOAuthAuthorisation(url, continuation))
@@ -324,9 +334,6 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
state.state = .error(.declined) state.state = .error(.declined)
case .linkingNotSupported: case .linkingNotSupported:
state.state = .error(.linkingNotSupported) state.state = .error(.linkingNotSupported)
case .expired where state.state.isDisplayQR:
state.state = .displayQR(.expired)
Task { await regenerateQRCode() }
case .expired: case .expired:
state.state = .error(.expired) state.state = .error(.expired)
case .slidingSyncNotAvailable: case .slidingSyncNotAvailable:
@@ -341,6 +348,7 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
/// Only for mocking initial states /// Only for mocking initial states
fileprivate init(state: QRCodeLoginState, mode: QRCodeLoginScreenMode, canSignInManually: Bool, checkCodeInput: String) { fileprivate init(state: QRCodeLoginState, mode: QRCodeLoginScreenMode, canSignInManually: Bool, checkCodeInput: String) {
appMediator = AppMediatorMock(.init()) appMediator = AppMediatorMock(.init())
linkDesktopTimeout = .seconds(3600)
super.init(initialViewState: .init(state: state, super.init(initialViewState: .init(state: state,
mode: mode, mode: mode,
canSignInManually: canSignInManually, canSignInManually: canSignInManually,
@@ -214,26 +214,19 @@ struct QRCodeLoginScreen: View {
@ViewBuilder @ViewBuilder
private var displayQRContent: some View { private var displayQRContent: some View {
if case let .displayQR(qrState) = context.viewState.state { if case let .displayQR(image) = context.viewState.state {
FullscreenDialog(topPadding: 24, horizontalPadding: 24) { FullscreenDialog(topPadding: 24, horizontalPadding: 24) {
VStack(spacing: 32) { VStack(spacing: 32) {
TitleAndIcon(title: L10n.screenLinkNewDeviceMobileTitle(InfoPlistReader.main.productionAppName), TitleAndIcon(title: L10n.screenLinkNewDeviceMobileTitle(InfoPlistReader.main.productionAppName),
icon: \.takePhotoSolid, icon: \.takePhotoSolid,
iconStyle: .default) iconStyle: .default)
switch qrState { Image(uiImage: image)
case .active(let image): .interpolation(.none) // to stop it getting blurred
Image(uiImage: image) .resizable()
.interpolation(.none) // to stop it getting blurred .scaledToFit()
.resizable() .frame(width: 200, height: 200)
.scaledToFit() .accessibilityLabel(L10n.a11yQrCode)
.frame(width: 200, height: 200)
.accessibilityLabel(L10n.a11yQrCode)
case .expired:
ProgressView()
.controlSize(.large)
.frame(width: 200, height: 200)
}
SFNumberedListView(items: context.viewState.instructions.linkMobileItems) SFNumberedListView(items: context.viewState.instructions.linkMobileItems)
} }
@@ -351,8 +344,7 @@ struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
static let deviceNotSignedInStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanFailed(.deviceNotSignedIn))) static let deviceNotSignedInStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanFailed(.deviceNotSignedIn)))
/// Showing /// Showing
static let showingStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayQR(.active(LinkNewDeviceServiceMock.mockQRCodeImage))) static let showingStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayQR(LinkNewDeviceServiceMock.mockQRCodeImage))
static let showingExpiredStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayQR(.expired))
// Displaying codes // Displaying codes
static let deviceCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.deviceCode("12"))) static let deviceCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.deviceCode("12")))
@@ -384,8 +376,6 @@ struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
ElementNavigationStack { QRCodeLoginScreen(context: showingStateViewModel.context) } ElementNavigationStack { QRCodeLoginScreen(context: showingStateViewModel.context) }
.previewDisplayName("Showing") .previewDisplayName("Showing")
ElementNavigationStack { QRCodeLoginScreen(context: showingExpiredStateViewModel.context) }
.previewDisplayName("Showing expired")
ElementNavigationStack { QRCodeLoginScreen(context: deviceCodeStateViewModel.context) } ElementNavigationStack { QRCodeLoginScreen(context: deviceCodeStateViewModel.context) }
.previewDisplayName("Device code") .previewDisplayName("Device code")
@@ -377,7 +377,7 @@ private extension HumanQrLoginError {
.qrCodeError(.deviceNotSignedIn) .qrCodeError(.deviceNotSignedIn)
case .UnsupportedQrCodeType: case .UnsupportedQrCodeType:
.qrCodeError(.invalidQRCode) .qrCodeError(.invalidQRCode)
case .Unknown, .OAuthMetadataInvalid, .CheckCodeAlreadySent, .CheckCodeCannotBeSent, .ContinuationCannotBeSent, .ContinuationAlreadySent: case .Unknown, .OAuthMetadataInvalid, .CheckCodeAlreadySent, .CheckCodeCannotBeSent, .ContinuationAlreadySent, .ContinuationCannotBeSent:
.qrCodeError(.unknown) .qrCodeError(.unknown)
} }
} }
@@ -29,7 +29,7 @@ class LinkNewDeviceService: LinkNewDeviceServiceProtocol {
case starting case starting
case qrReady(UIImage) case qrReady(UIImage)
case qrScanned(CheckCodeSenderProxy) case qrScanned(CheckCodeSenderProxy)
case waitingForAuthorisation(verificationURL: URL) case waitingForAuthorisation(verificationURL: URL, continuationSender: ContinuationMessageSenderProxy)
case syncingSecrets case syncingSecrets
case done case done
} }
@@ -38,7 +38,7 @@ class LinkNewDeviceService: LinkNewDeviceServiceProtocol {
enum LinkDesktopProgress: Equatable { enum LinkDesktopProgress: Equatable {
case starting case starting
case establishingSecureChannel(checkCodeString: String) case establishingSecureChannel(checkCodeString: String)
case waitingForAuthorisation(verificationURL: URL) case waitingForAuthorisation(verificationURL: URL, continuationSender: ContinuationMessageSenderProxy)
case syncingSecrets case syncingSecrets
case done case done
} }
@@ -163,10 +163,10 @@ extension LinkNewDeviceService.LinkMobileProgress: CustomStringConvertible {
throw Error.invalidQRCodeData throw Error.invalidQRCodeData
} }
case .qrScanned(let checkCodeSender): .qrScanned(.init(underlyingSender: checkCodeSender)) case .qrScanned(let checkCodeSender): .qrScanned(.init(underlyingSender: checkCodeSender))
case .waitingForAuth(let verificationURI, _): case .waitingForAuth(let verificationURI, let continuationSender):
// verificationURI is a String; ASWebAuthenticationSession requires a URL. // verificationURI is a String; ASWebAuthenticationSession requires a URL.
if let url = URL(string: verificationURI) { if let url = URL(string: verificationURI) {
.waitingForAuthorisation(verificationURL: url) .waitingForAuthorisation(verificationURL: url, continuationSender: .init(underlyingSender: continuationSender))
} else { } else {
throw Error.invalidVerificationURI(verificationURI) throw Error.invalidVerificationURI(verificationURI)
} }
@@ -194,10 +194,10 @@ extension LinkNewDeviceService.LinkDesktopProgress: CustomStringConvertible {
self = switch rustProgress { self = switch rustProgress {
case .starting: .starting case .starting: .starting
case .establishingSecureChannel(_, let checkCodeString): .establishingSecureChannel(checkCodeString: checkCodeString) case .establishingSecureChannel(_, let checkCodeString): .establishingSecureChannel(checkCodeString: checkCodeString)
case .waitingForAuth(let verificationURI, _): case .waitingForAuth(let verificationURI, let continuationSender):
// verificationURI is a String; ASWebAuthenticationSession requires a URL. // verificationURI is a String; ASWebAuthenticationSession requires a URL.
if let url = URL(string: verificationURI) { if let url = URL(string: verificationURI) {
.waitingForAuthorisation(verificationURL: url) .waitingForAuthorisation(verificationURL: url, continuationSender: .init(underlyingSender: continuationSender))
} else { } else {
throw Error.invalidVerificationURI(verificationURI) throw Error.invalidVerificationURI(verificationURI)
} }
@@ -240,8 +240,9 @@ private extension QRCodeLoginError {
} }
} }
nonisolated class CheckCodeSenderProxy: Equatable { final nonisolated class CheckCodeSenderProxy: Equatable, Sendable {
static func == (lhs: CheckCodeSenderProxy, rhs: CheckCodeSenderProxy) -> Bool { static func == (lhs: CheckCodeSenderProxy, rhs: CheckCodeSenderProxy) -> Bool {
// Not ideal but good enough as the equality check is purely for removing duplicates from the progress stream.
lhs.underlyingSender === rhs.underlyingSender lhs.underlyingSender === rhs.underlyingSender
} }
@@ -256,6 +257,35 @@ nonisolated class CheckCodeSenderProxy: Equatable {
} }
} }
final nonisolated class ContinuationMessageSenderProxy: Equatable, Sendable {
static func == (lhs: ContinuationMessageSenderProxy, rhs: ContinuationMessageSenderProxy) -> Bool {
// Not ideal but good enough as the equality check is purely for removing duplicates from the progress stream.
lhs.underlyingSender === rhs.underlyingSender
}
let underlyingSender: ContinuationMessageSenderProtocol
init(underlyingSender: ContinuationMessageSenderProtocol) {
self.underlyingSender = underlyingSender
}
func confirm() async {
do {
try await underlyingSender.confirm()
} catch {
MXLog.error("Failed to confirm the continuation of the login grant: \(error)")
}
}
func cancel() async {
do {
try await underlyingSender.cancel()
} catch {
MXLog.error("Failed to cancel the login grant: \(error)")
}
}
}
extension UIImage { extension UIImage {
convenience init?(qrCodeData: Data) { convenience init?(qrCodeData: Data) {
let qrContext = CIContext() let qrContext = CIContext()
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:35b2338f432dd2d65581f94680382a33af87881bfd36e2a426a96f879ed1d93f
size 119474
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3f34b4d5fb95d223b8b1cf850fe74fc75d031da0a7f0a359b5a4c3e29fa83669
size 142744
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c86cc8592649e4d3e65a6632fd02756455ffd48bbdc35b12d7db5a8ba147c988
size 70650
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2bbc55c8ae626a1e652864927dfa2eab2860c85dca9372cca73e0af982d7ca41
size 96693
@@ -9,7 +9,6 @@
import Combine import Combine
@testable import ElementX @testable import ElementX
import MatrixRustSDKMocks import MatrixRustSDKMocks
import SwiftUI
import Testing import Testing
@MainActor @MainActor
@@ -19,7 +18,6 @@ struct QRCodeLoginScreenViewModelTests {
var qrLoginProgressSubject: CurrentValueSubject<QRLoginProgress, AuthenticationServiceError>! var qrLoginProgressSubject: CurrentValueSubject<QRLoginProgress, AuthenticationServiceError>!
var qrCodeLoginService: QRCodeLoginServiceMock! var qrCodeLoginService: QRCodeLoginServiceMock!
var regeneratedQRCodeImage: UIImage!
var linkMobileProgressSubject: CurrentValueSubject<LinkNewDeviceService.LinkMobileProgress, QRCodeLoginError>! var linkMobileProgressSubject: CurrentValueSubject<LinkNewDeviceService.LinkMobileProgress, QRCodeLoginError>!
var linkDesktopProgressSubject: CurrentValueSubject<LinkNewDeviceService.LinkDesktopProgress, QRCodeLoginError>! var linkDesktopProgressSubject: CurrentValueSubject<LinkNewDeviceService.LinkDesktopProgress, QRCodeLoginError>!
var linkNewDeviceService: LinkNewDeviceServiceMock! var linkNewDeviceService: LinkNewDeviceServiceMock!
@@ -152,7 +150,8 @@ struct QRCodeLoginScreenViewModelTests {
guard case .requestOAuthAuthorisation = action else { return false } guard case .requestOAuthAuthorisation = action else { return false }
return true return true
} }
linkDesktopProgressSubject.send(.waitingForAuthorisation(verificationURL: .homeDirectory)) linkDesktopProgressSubject.send(.waitingForAuthorisation(verificationURL: .homeDirectory,
continuationSender: .init(underlyingSender: ContinuationMessageSenderSDKMock())))
try await deferredAction.fulfill() try await deferredAction.fulfill()
let currentState = context.viewState.state let currentState = context.viewState.state
@@ -168,6 +167,25 @@ struct QRCodeLoginScreenViewModelTests {
try await deferredAction.fulfill() try await deferredAction.fulfill()
} }
@Test
mutating func linkDesktopExpiresWithoutSecureChannel() async throws {
setup(mode: .linkDesktop, linkDesktopTimeout: .zero)
#expect(context.viewState.state == .linkDesktopInstructions)
// The timeout begins as soon as the screen appears, so the flow expires before a channel is established.
let deferred = deferFulfillment(context.$viewState) { $0.state == .error(.expired) }
try await deferred.fulfill()
}
@Test
mutating func loginDoesNotExpire() async throws {
setup(mode: .login, linkDesktopTimeout: .zero)
// The timeout only applies when linking a desktop, so signing in must never expire.
let deferredFailure = deferFailure(context.$viewState, timeout: .seconds(1)) { $0.state == .error(.expired) }
try await deferredFailure.fulfill()
}
@Test @Test
mutating func linkMobileDevice() async throws { mutating func linkMobileDevice() async throws {
setup(mode: .linkMobile) setup(mode: .linkMobile)
@@ -188,7 +206,8 @@ struct QRCodeLoginScreenViewModelTests {
guard case .requestOAuthAuthorisation = action else { return false } guard case .requestOAuthAuthorisation = action else { return false }
return true return true
} }
linkMobileProgressSubject.send(.waitingForAuthorisation(verificationURL: .homeDirectory)) linkMobileProgressSubject.send(.waitingForAuthorisation(verificationURL: .homeDirectory,
continuationSender: .init(underlyingSender: ContinuationMessageSenderSDKMock())))
try await deferredAction.fulfill() try await deferredAction.fulfill()
let currentState = context.viewState.state let currentState = context.viewState.state
@@ -204,20 +223,9 @@ struct QRCodeLoginScreenViewModelTests {
try await deferredAction.fulfill() try await deferredAction.fulfill()
} }
@Test
mutating func linkMobileDeviceQRCodeExpiry() async throws {
setup(mode: .linkMobile)
#expect(context.viewState.state.isDisplayQR)
let deferred = deferFulfillment(context.$viewState, keyPath: \.state, transitionValues: [.displayQR(.expired),
.displayQR(.active(regeneratedQRCodeImage))])
linkMobileProgressSubject.send(completion: .failure(.expired))
try await deferred.fulfill()
}
// MARK: - Helpers // MARK: - Helpers
private mutating func setup(mode: Mode) { private mutating func setup(mode: Mode, linkDesktopTimeout: Duration = .seconds(120)) {
qrLoginProgressSubject = .init(.starting) qrLoginProgressSubject = .init(.starting)
qrCodeLoginService = QRCodeLoginServiceMock() qrCodeLoginService = QRCodeLoginServiceMock()
qrCodeLoginService.loginWithQRCodeDataReturnValue = qrLoginProgressSubject.asCurrentValuePublisher() qrCodeLoginService.loginWithQRCodeDataReturnValue = qrLoginProgressSubject.asCurrentValuePublisher()
@@ -227,11 +235,6 @@ struct QRCodeLoginScreenViewModelTests {
linkNewDeviceService = LinkNewDeviceServiceMock(.init(linkMobileProgressPublisher: linkMobileProgressSubject.asCurrentValuePublisher(), linkNewDeviceService = LinkNewDeviceServiceMock(.init(linkMobileProgressPublisher: linkMobileProgressSubject.asCurrentValuePublisher(),
linkDesktopProgressPublisher: linkDesktopProgressSubject.asCurrentValuePublisher())) linkDesktopProgressPublisher: linkDesktopProgressSubject.asCurrentValuePublisher()))
regeneratedQRCodeImage = LinkNewDeviceServiceMock.mockQRCodeImage
let clientProxy = ClientProxyMock(.init())
clientProxy.linkNewDeviceServiceReturnValue = LinkNewDeviceServiceMock(.init(linkMobileProgressPublisher: .init(.qrReady(regeneratedQRCodeImage)),
linkDesktopProgressPublisher: .init(.starting)))
let screenMode: QRCodeLoginScreenMode let screenMode: QRCodeLoginScreenMode
switch mode { switch mode {
case .login: case .login:
@@ -239,12 +242,13 @@ struct QRCodeLoginScreenViewModelTests {
case .linkDesktop: case .linkDesktop:
screenMode = .linkDesktop(linkNewDeviceService) screenMode = .linkDesktop(linkNewDeviceService)
case .linkMobile: case .linkMobile:
screenMode = .linkMobile(linkNewDeviceService.linkMobileDevice(), clientProxy) screenMode = .linkMobile(linkNewDeviceService.linkMobileDevice())
} }
appMediator = AppMediatorMock(.init()) appMediator = AppMediatorMock(.init())
viewModel = QRCodeLoginScreenViewModel(mode: screenMode, viewModel = QRCodeLoginScreenViewModel(mode: screenMode,
canSignInManually: true, canSignInManually: true,
appMediator: appMediator) appMediator: appMediator,
linkDesktopTimeout: linkDesktopTimeout)
} }
} }