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
@@ -9,7 +9,6 @@
import Combine
@testable import ElementX
import MatrixRustSDKMocks
import SwiftUI
import Testing
@MainActor
@@ -19,7 +18,6 @@ struct QRCodeLoginScreenViewModelTests {
var qrLoginProgressSubject: CurrentValueSubject<QRLoginProgress, AuthenticationServiceError>!
var qrCodeLoginService: QRCodeLoginServiceMock!
var regeneratedQRCodeImage: UIImage!
var linkMobileProgressSubject: CurrentValueSubject<LinkNewDeviceService.LinkMobileProgress, QRCodeLoginError>!
var linkDesktopProgressSubject: CurrentValueSubject<LinkNewDeviceService.LinkDesktopProgress, QRCodeLoginError>!
var linkNewDeviceService: LinkNewDeviceServiceMock!
@@ -152,7 +150,8 @@ struct QRCodeLoginScreenViewModelTests {
guard case .requestOAuthAuthorisation = action else { return false }
return true
}
linkDesktopProgressSubject.send(.waitingForAuthorisation(verificationURL: .homeDirectory))
linkDesktopProgressSubject.send(.waitingForAuthorisation(verificationURL: .homeDirectory,
continuationSender: .init(underlyingSender: ContinuationMessageSenderSDKMock())))
try await deferredAction.fulfill()
let currentState = context.viewState.state
@@ -168,6 +167,25 @@ struct QRCodeLoginScreenViewModelTests {
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
mutating func linkMobileDevice() async throws {
setup(mode: .linkMobile)
@@ -188,7 +206,8 @@ struct QRCodeLoginScreenViewModelTests {
guard case .requestOAuthAuthorisation = action else { return false }
return true
}
linkMobileProgressSubject.send(.waitingForAuthorisation(verificationURL: .homeDirectory))
linkMobileProgressSubject.send(.waitingForAuthorisation(verificationURL: .homeDirectory,
continuationSender: .init(underlyingSender: ContinuationMessageSenderSDKMock())))
try await deferredAction.fulfill()
let currentState = context.viewState.state
@@ -204,20 +223,9 @@ struct QRCodeLoginScreenViewModelTests {
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
private mutating func setup(mode: Mode) {
private mutating func setup(mode: Mode, linkDesktopTimeout: Duration = .seconds(120)) {
qrLoginProgressSubject = .init(.starting)
qrCodeLoginService = QRCodeLoginServiceMock()
qrCodeLoginService.loginWithQRCodeDataReturnValue = qrLoginProgressSubject.asCurrentValuePublisher()
@@ -227,11 +235,6 @@ struct QRCodeLoginScreenViewModelTests {
linkNewDeviceService = LinkNewDeviceServiceMock(.init(linkMobileProgressPublisher: linkMobileProgressSubject.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
switch mode {
case .login:
@@ -239,12 +242,13 @@ struct QRCodeLoginScreenViewModelTests {
case .linkDesktop:
screenMode = .linkDesktop(linkNewDeviceService)
case .linkMobile:
screenMode = .linkMobile(linkNewDeviceService.linkMobileDevice(), clientProxy)
screenMode = .linkMobile(linkNewDeviceService.linkMobileDevice())
}
appMediator = AppMediatorMock(.init())
viewModel = QRCodeLoginScreenViewModel(mode: screenMode,
canSignInManually: true,
appMediator: appMediator)
appMediator: appMediator,
linkDesktopTimeout: linkDesktopTimeout)
}
}