// // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import XCTest @testable import SignalServiceKit final class InactiveLinkedDeviceFinderTest: XCTestCase { private var mockDateProvider: DateProvider! private var mockDB: (any DB)! private var mockDeviceNameDecrypter: MockDeviceNameDecrypter! private var mockDeviceStore: MockDeviceStore! private var mockDevicesService: MockDevicesService! private var mockTSAccountManager: MockTSAccountManager! private var inactiveLinkedDeviceFinder: InactiveLinkedDeviceFinderImpl! private var activeLastSeenAt: Date { return mockDateProvider() .addingTimeInterval(-.minute) } private var inactiveLastSeenAt: Date { // The finder will consider anything not seen for (1 month - 1 week) to // be inactive, so we'll go back exactly that far and then go one more // hour back to avoid any boundary-time issues. return mockDateProvider() .addingTimeInterval(-45 * .day) .addingTimeInterval(.week) .addingTimeInterval(-.hour) } override func setUp() { // Use the same date for all usages of the date provider across a test. let nowDate = Date() mockDateProvider = { nowDate } mockDB = InMemoryDB() mockDeviceNameDecrypter = MockDeviceNameDecrypter() mockDeviceStore = MockDeviceStore() mockDevicesService = MockDevicesService() mockTSAccountManager = MockTSAccountManager() inactiveLinkedDeviceFinder = InactiveLinkedDeviceFinderImpl( dateProvider: { self.mockDateProvider() }, db: mockDB, deviceNameDecrypter: mockDeviceNameDecrypter, deviceService: mockDevicesService, deviceStore: mockDeviceStore, remoteConfigProvider: MockRemoteConfigProvider(), tsAccountManager: mockTSAccountManager ) } func testRefreshing() async { // Skip if linked device. mockTSAccountManager.registrationStateMock = { .provisioned } await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary() XCTAssertEqual(mockDevicesService.refreshCount, 0) // Make a first attempt, failing to refresh. mockTSAccountManager.registrationStateMock = { .registered } mockDevicesService.shouldFail = true await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary() XCTAssertEqual(mockDevicesService.refreshCount, 1) // Make a second attempt, succeeding. mockTSAccountManager.registrationStateMock = { .registered } mockDevicesService.shouldFail = false await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary() XCTAssertEqual(mockDevicesService.refreshCount, 2) // A third attempt should do nothing, because we just succeeded. await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary() XCTAssertEqual(mockDevicesService.refreshCount, 2) } func testFetching() async { func findLeastActive() -> InactiveLinkedDevice? { return mockDB.read { inactiveLinkedDeviceFinder.findLeastActiveLinkedDevice(tx: $0) } } // Nothing if never refreshed. mockTSAccountManager.registrationStateMock = { .registered } mockDeviceStore.devices = [ .primary(), .fixture(name: "eye pad", lastSeenAt: inactiveLastSeenAt), ] XCTAssertNil(findLeastActive()) // Do a refresh... mockTSAccountManager.registrationStateMock = { .registered } await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary() XCTAssertEqual(mockDevicesService.refreshCount, 1) // Only include inactive devices. mockTSAccountManager.registrationStateMock = { .registered } mockDeviceStore.devices = [ .primary(), .fixture(name: "eye pad", lastSeenAt: inactiveLastSeenAt), .fixture(name: "lap top", lastSeenAt: activeLastSeenAt) ] XCTAssertEqual( findLeastActive(), InactiveLinkedDevice( displayName: "eye pad", expirationDate: inactiveLastSeenAt.addingTimeInterval(45 * .day) ) ) // If multiple inactive devices, pick the "least active" one. mockTSAccountManager.registrationStateMock = { .registered } mockDeviceStore.devices = [ .primary(), .fixture(name: "🏖️", lastSeenAt: inactiveLastSeenAt.addingTimeInterval(-.second)), .fixture(name: "🦩", lastSeenAt: inactiveLastSeenAt), ] XCTAssertEqual( findLeastActive(), InactiveLinkedDevice( displayName: "🏖️", expirationDate: inactiveLastSeenAt.addingTimeInterval(-.second).addingTimeInterval(45 * .day) ) ) // Nothing if no linked devices. mockTSAccountManager.registrationStateMock = { .registered } mockDeviceStore.devices = [.primary()] XCTAssertNil(findLeastActive()) // Nothing if not a primary. mockTSAccountManager.registrationStateMock = { .provisioned } mockDeviceStore.devices = [ .primary(), .fixture(name: "eye pad", lastSeenAt: inactiveLastSeenAt), ] XCTAssertNil(findLeastActive()) } func testPermanentlyDisabling() async { mockTSAccountManager.registrationStateMock = { .registered } mockDeviceStore.devices = [ .primary(), .fixture(name: "a sedentary device", lastSeenAt: inactiveLastSeenAt), ] mockDB.write { inactiveLinkedDeviceFinder.permanentlyDisableFinders(tx: $0) } await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary() XCTAssertEqual(mockDevicesService.refreshCount, 0) XCTAssertFalse(mockDB.read { inactiveLinkedDeviceFinder.hasInactiveLinkedDevice(tx: $0) }) // Re-enable (only available in tests) and run more tests, to prove the // disabling is why the first battery passed. mockDB.write { inactiveLinkedDeviceFinder.reenablePermanentlyDisabledFinders(tx: $0) } await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary() XCTAssertEqual(mockDevicesService.refreshCount, 1) XCTAssertTrue(mockDB.read { inactiveLinkedDeviceFinder.hasInactiveLinkedDevice(tx: $0) }) } } private extension OWSDevice { static func primary() -> OWSDevice { return OWSDevice( deviceId: Int(OWSDevice.primaryDeviceId), encryptedName: nil, createdAt: .distantPast, lastSeenAt: Date() ) } static func fixture( name: String, lastSeenAt: Date ) -> OWSDevice { return OWSDevice( deviceId: 24, encryptedName: name, createdAt: .distantPast, lastSeenAt: lastSeenAt ) } } // MARK: - Mocks private class MockDeviceNameDecrypter: InactiveLinkedDeviceFinderImpl.Shims.OWSDeviceNameDecrypter { func decryptName(device: OWSDevice, tx: DBReadTransaction) -> String { return device.encryptedName! } } private class MockDeviceStore: OWSDeviceStore { var devices: [OWSDevice] = [] func fetchAll(tx: DBReadTransaction) -> [OWSDevice] { return devices } func replaceAll(with newDevices: [OWSDevice], tx: DBWriteTransaction) -> Bool { let isChanging = devices.count == newDevices.count devices = newDevices return isChanging } func remove(_ device: OWSDevice, tx: DBWriteTransaction) { devices.removeAll { _device in device.deviceId == _device.deviceId } } func setEncryptedName(_ encryptedName: String, for device: OWSDevice, tx: DBWriteTransaction) { device.encryptedName = encryptedName } func mostRecentlyLinkedDeviceDetails(tx: DBReadTransaction) throws -> MostRecentlyLinkedDeviceDetails? { nil } func setMostRecentlyLinkedDeviceDetails(linkedTime: Date, notificationDelay: TimeInterval, tx: DBWriteTransaction) throws { } func clearMostRecentlyLinkedDeviceDetails(tx: DBWriteTransaction) { } } private class MockDevicesService: OWSDeviceService { var shouldFail: Bool = false var refreshCount: Int = 0 func refreshDevices() async throws -> Bool { refreshCount += 1 if shouldFail { throw OWSGenericError("") } return true } func unlinkDevice(deviceId: DeviceId, auth: ChatServiceAuth) async throws {} func renameDevice(device: OWSDevice, toEncryptedName encryptedName: String) async throws {} }