From f0da18e71c47364503caeafb9c1376e6e697fd7b Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Sat, 20 Jun 2026 15:35:38 +0200 Subject: [PATCH 1/2] fix(activesync): complete account-only remote wipe per user Store account-only wipe status per device user, stop the provision loop after client ack, block sync for wiped accounts, and reject further provisioning so iOS does not receive new policy keys. Co-authored-by: Cursor --- lib/Horde/ActiveSync/Collections.php | 8 ++- lib/Horde/ActiveSync/Request/Base.php | 13 +++- lib/Horde/ActiveSync/Request/Provision.php | 61 +++++++++++++------ lib/Horde/ActiveSync/State/Base.php | 36 +++++++++++ lib/Horde/ActiveSync/State/Mongo.php | 55 +++++++++++++++-- lib/Horde/ActiveSync/State/Sql.php | 50 +++++++++++++-- ...ctivesync_peruser_accountonly_rwstatus.php | 36 +++++++++++ 7 files changed, 229 insertions(+), 30 deletions(-) create mode 100644 migration/Horde/ActiveSync/25_horde_activesync_peruser_accountonly_rwstatus.php diff --git a/lib/Horde/ActiveSync/Collections.php b/lib/Horde/ActiveSync/Collections.php index bd43d8d0..c537388f 100644 --- a/lib/Horde/ActiveSync/Collections.php +++ b/lib/Horde/ActiveSync/Collections.php @@ -1232,10 +1232,14 @@ public function pollForChanges($heartbeat, $interval, array $options = []) $rw_check_countdown = 5; if ($this->_as->provisioning != Horde_ActiveSync::PROVISIONING_NONE) { $rwstatus = $this->_as->state->getDeviceRWStatus($this->_as->device->id, true); + $accountOnlyStatus = $this->_as->state->getAccountOnlyRWStatus( + $this->_as->device->id, + true + ); if ($rwstatus == Horde_ActiveSync::RWSTATUS_PENDING || $rwstatus == Horde_ActiveSync::RWSTATUS_WIPED - || $rwstatus == Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING - || $rwstatus == Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_WIPED) { + || $accountOnlyStatus == Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING + || $accountOnlyStatus == Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_WIPED) { return self::COLLECTION_ERR_FOLDERSYNC_REQUIRED; } } diff --git a/lib/Horde/ActiveSync/Request/Base.php b/lib/Horde/ActiveSync/Request/Base.php index b47a9590..211200e8 100644 --- a/lib/Horde/ActiveSync/Request/Base.php +++ b/lib/Horde/ActiveSync/Request/Base.php @@ -185,12 +185,21 @@ public function checkPolicyKey($sentKey, $requestType = null) // Did we request a remote wipe? $rwStatus = $this->_state->getDeviceRWStatus($this->_device->id); - if ($rwStatus == Horde_ActiveSync::RWSTATUS_PENDING - || $rwStatus == Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING) { + if ($rwStatus == Horde_ActiveSync::RWSTATUS_PENDING) { $this->_requireProvisionWbxml($requestType, Horde_ActiveSync_Status::REMOTEWIPE_REQUESTED); return false; } + $accountOnlyStatus = $this->_state->getAccountOnlyRWStatus($this->_device->id); + if ($accountOnlyStatus == Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING) { + $this->_requireProvisionWbxml($requestType, Horde_ActiveSync_Status::REMOTEWIPE_REQUESTED); + return false; + } + if ($accountOnlyStatus == Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_WIPED) { + $this->_requireProvisionWbxml($requestType, Horde_ActiveSync_Status::DEVICE_NOT_PROVISIONED); + return false; + } + // Validate the stored key against the device key, honoring // the value of _provisioning. if ((empty($storedKey) || $storedKey != $sentKey) diff --git a/lib/Horde/ActiveSync/Request/Provision.php b/lib/Horde/ActiveSync/Request/Provision.php index 5f3721f7..b3df3134 100644 --- a/lib/Horde/ActiveSync/Request/Provision.php +++ b/lib/Horde/ActiveSync/Request/Provision.php @@ -89,15 +89,23 @@ protected function _handle() return $this->_globalError(self::STATUS_PROTERROR); } if ($status == self::STATUS_CLIENT_SUCCESS) { + if ($wipeTag === Horde_ActiveSync::PROVISION_ACCOUNTONLYREMOTEWIPE) { + return $this->_completeAccountOnlyWipeAck(); + } $this->_state->setDeviceRWStatus( - $this->_devId, - $wipeTag === Horde_ActiveSync::PROVISION_ACCOUNTONLYREMOTEWIPE - ? Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_WIPED - : Horde_ActiveSync::RWSTATUS_WIPED + $this->_device->id, + Horde_ActiveSync::RWSTATUS_WIPED ); } $policytype = Horde_ActiveSync::POLICYTYPE_XML; } else { + if ($this->_state->getAccountOnlyRWStatus($this->_device->id) + == Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_WIPED) { + $this->_sendNoProvisionNeededResponse(self::STATUS_SUCCESS); + + return true; + } + if ($deviceinfo = $this->_handleSettings()) { $deviceinfo['version'] = $this->_device->version; $this->_device->setDeviceProperties($deviceinfo); @@ -177,11 +185,12 @@ protected function _handle() return $this->_globalError(self::STATUS_PROTERROR); } if ($status == self::STATUS_CLIENT_SUCCESS) { + if ($wipeTag === Horde_ActiveSync::PROVISION_ACCOUNTONLYREMOTEWIPE) { + return $this->_completeAccountOnlyWipeAck(); + } $this->_state->setDeviceRWStatus( $this->_device->id, - $wipeTag === Horde_ActiveSync::PROVISION_ACCOUNTONLYREMOTEWIPE - ? Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_WIPED - : Horde_ActiveSync::RWSTATUS_WIPED + Horde_ActiveSync::RWSTATUS_WIPED ); } } @@ -282,23 +291,22 @@ protected function _handle() // Remote wipe if requested. $rwstatus = $this->_state->getDeviceRWStatus($this->_device->id); if ($rwstatus == Horde_ActiveSync::RWSTATUS_PENDING - || $rwstatus == Horde_ActiveSync::RWSTATUS_WIPED - || $rwstatus == Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING - || $rwstatus == Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_WIPED) { - $isAccountOnly = ($rwstatus == Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING - || $rwstatus == Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_WIPED); + || $rwstatus == Horde_ActiveSync::RWSTATUS_WIPED) { $this->_encoder->startTag( - $isAccountOnly - ? Horde_ActiveSync::PROVISION_ACCOUNTONLYREMOTEWIPE - : Horde_ActiveSync::PROVISION_REMOTEWIPE, + Horde_ActiveSync::PROVISION_REMOTEWIPE, false, true ); $this->_state->setDeviceRWStatus( $this->_device->id, - $isAccountOnly - ? Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_WIPED - : Horde_ActiveSync::RWSTATUS_WIPED + Horde_ActiveSync::RWSTATUS_WIPED + ); + } elseif ($this->_state->getAccountOnlyRWStatus($this->_device->id) + == Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING) { + $this->_encoder->startTag( + Horde_ActiveSync::PROVISION_ACCOUNTONLYREMOTEWIPE, + false, + true ); } $this->_encoder->endTag(); //provision @@ -306,6 +314,23 @@ protected function _handle() return true; } + /** + * Finalize a successful account-only remote wipe acknowledgment. + * + * @return boolean + */ + protected function _completeAccountOnlyWipeAck() + { + $this->_state->setAccountOnlyRWStatus( + $this->_device->id, + $this->_device->user, + Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_WIPED + ); + $this->_sendNoProvisionNeededResponse(self::STATUS_SUCCESS); + + return true; + } + /** * Send a WBXML response to the output stream indicating that no * provision requests are necessary. diff --git a/lib/Horde/ActiveSync/State/Base.php b/lib/Horde/ActiveSync/State/Base.php index a4ffe850..9a10ba4d 100644 --- a/lib/Horde/ActiveSync/State/Base.php +++ b/lib/Horde/ActiveSync/State/Base.php @@ -371,6 +371,31 @@ public function getDeviceRWStatus($devId, $refresh = false) return $this->_deviceInfo->rwstatus; } + /** + * Obtain the account-only remote wipe status for the loaded device user. + * + * @param string $devId The device id. + * @param boolean $refresh If true, reload from storage. + * + * @return integer + */ + public function getAccountOnlyRWStatus($devId, $refresh = false) + { + if (empty($this->_deviceInfo) || $this->_deviceInfo->id != $devId) { + throw new Horde_ActiveSync_Exception('Device not loaded.'); + } + + if ($refresh) { + $this->loadDeviceInfo( + $this->_deviceInfo->id, + $this->_deviceInfo->user, + ['force' => true] + ); + } + + return $this->_deviceInfo->accountOnlyRwstatus; + } + /** * Set the backend driver * (should really only be called by a backend object when passing this @@ -1323,6 +1348,17 @@ abstract public function resetAllPolicyKeys(); */ abstract public function setDeviceRWStatus($devId, $status); + /** + * Set account-only remote wipe status for a device user. + * + * @param string $devId The device id. + * @param string $user The device user. + * @param string $status A Horde_ActiveSync::RWSTATUS_* constant. + * + * @throws Horde_ActiveSync_Exception + */ + abstract public function setAccountOnlyRWStatus($devId, $user, $status); + /** * Obtain the device object. * diff --git a/lib/Horde/ActiveSync/State/Mongo.php b/lib/Horde/ActiveSync/State/Mongo.php index de8dd8ca..5d689f84 100644 --- a/lib/Horde/ActiveSync/State/Mongo.php +++ b/lib/Horde/ActiveSync/State/Mongo.php @@ -111,6 +111,8 @@ class Horde_ActiveSync_State_Mongo extends Horde_ActiveSync_State_Base implement public const DEVICE_USER = 'device_user'; public const DEVICE_USERS_USER = 'users.device_user'; public const DEVICE_USERS_POLICYKEY = 'users.device_policykey'; + public const DEVICE_ACCOUNTONLY_RWSTATUS = 'device_accountonly_rwstatus'; + public const DEVICE_USERS_ACCOUNTONLY_RWSTATUS = 'users.device_accountonly_rwstatus'; public const DEVICE_POLICYKEY = 'device_policykey'; /** @@ -1120,6 +1122,8 @@ public function loadDeviceInfo($devId, $user = null, $params = []) foreach ($device_data['users'] as $user_entry) { if ($user_entry[self::DEVICE_USER] == $user) { $device['policykey'] = $user_entry[self::DEVICE_POLICYKEY]; + $device['accountOnlyRwstatus'] = $user_entry[self::DEVICE_ACCOUNTONLY_RWSTATUS] + ?? Horde_ActiveSync::RWSTATUS_NA; break; } } @@ -1357,8 +1361,7 @@ public function setDeviceRWStatus($devId, $status) throw new Horde_ActiveSync_Exception($e); } - if ($status == Horde_ActiveSync::RWSTATUS_PENDING - || $status == Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING) { + if ($status == Horde_ActiveSync::RWSTATUS_PENDING) { $new_data[self::DEVICE_USERS_POLICYKEY] = 0; $cursor = $this->_db->selectCollection(self::COLLECTION_DEVICE) ->find($query, ['users' => true]); @@ -1379,6 +1382,52 @@ public function setDeviceRWStatus($devId, $status) } } + /** + * Set account-only remote wipe status for a device user. + * + * @param string $devId The device id. + * @param string $user The device user. + * @param string $status A Horde_ActiveSync::RWSTATUS_* constant. + * + * @throws Horde_ActiveSync_Exception + */ + public function setAccountOnlyRWStatus($devId, $user, $status) + { + $query = [ + self::MONGO_ID => $devId, + self::DEVICE_USERS_USER => $user, + ]; + $update = [ + '$set' => [ + self::DEVICE_USERS_ACCOUNTONLY_RWSTATUS => $status, + ], + ]; + try { + $this->_db->selectCollection(self::COLLECTION_DEVICE)->update($query, $update); + } catch (Exception $e) { + $this->_logger->err($e->getMessage()); + throw new Horde_ActiveSync_Exception($e); + } + + if ($status == Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING) { + try { + $this->_db->selectCollection(self::COLLECTION_DEVICE)->update( + $query, + ['$set' => [self::DEVICE_USERS_POLICYKEY => 0]] + ); + } catch (Exception $e) { + $this->_logger->err($e->getMessage()); + throw new Horde_ActiveSync_Exception($e); + } + } + + if (!empty($this->_deviceInfo) + && $this->_deviceInfo->id == $devId + && $this->_deviceInfo->user == $user) { + $this->_deviceInfo->accountOnlyRwstatus = $status; + } + } + /** * Reset the sync state for this device, for the specified collection. * @@ -1518,8 +1567,6 @@ public function removeState(array $options) '$or' => [ [self::DEVICE_RWSTATUS => Horde_ActiveSync::RWSTATUS_PENDING], [self::DEVICE_RWSTATUS => Horde_ActiveSync::RWSTATUS_WIPED], - [self::DEVICE_RWSTATUS => Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING], - [self::DEVICE_RWSTATUS => Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_WIPED], ], ]; try { diff --git a/lib/Horde/ActiveSync/State/Sql.php b/lib/Horde/ActiveSync/State/Sql.php index 74b311e1..54d63bf5 100644 --- a/lib/Horde/ActiveSync/State/Sql.php +++ b/lib/Horde/ActiveSync/State/Sql.php @@ -1174,7 +1174,8 @@ public function loadDeviceInfo($devId, $user = null, $params = []) } if (!empty($user)) { - $query = 'SELECT device_policykey FROM ' . $this->_syncUsersTable + $query = 'SELECT device_policykey, device_accountonly_rwstatus FROM ' + . $this->_syncUsersTable . ' WHERE device_id = ? AND device_user = ?'; try { $duser = $this->_db->selectOne($query, [$devId, $user]); @@ -1185,6 +1186,9 @@ public function loadDeviceInfo($devId, $user = null, $params = []) $this->_deviceInfo = new Horde_ActiveSync_Device($this); $this->_deviceInfo->rwstatus = $device['device_rwstatus']; + $this->_deviceInfo->accountOnlyRwstatus = !empty($duser['device_accountonly_rwstatus']) + ? $duser['device_accountonly_rwstatus'] + : Horde_ActiveSync::RWSTATUS_NA; $this->_deviceInfo->deviceType = $device['device_type']; $this->_deviceInfo->userAgent = $device['device_agent']; $this->_deviceInfo->id = $devId; @@ -1351,7 +1355,8 @@ public function deviceExists($devId, $user = null) public function listDevices($user = null, $filter = []) { $query = 'SELECT d.device_id AS device_id, device_type, device_agent,' - . ' device_policykey, device_rwstatus, device_user, device_properties FROM ' + . ' device_policykey, device_rwstatus, device_accountonly_rwstatus,' + . ' device_user, device_properties FROM ' . $this->_syncDeviceTable . ' d INNER JOIN ' . $this->_syncUsersTable . ' u ON d.device_id = u.device_id'; $values = []; @@ -1470,8 +1475,7 @@ public function setDeviceRWStatus($devId, $status) throw new Horde_ActiveSync_Exception($e); } - if ($status == Horde_ActiveSync::RWSTATUS_PENDING - || $status == Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING) { + if ($status == Horde_ActiveSync::RWSTATUS_PENDING) { // Need to clear the policykey to force a PROVISION. Clear ALL // entries, to ensure the device is wiped. $query = 'UPDATE ' . $this->_syncUsersTable @@ -1484,6 +1488,44 @@ public function setDeviceRWStatus($devId, $status) } } + /** + * Set account-only remote wipe status for a device user. + * + * @param string $devId The device id. + * @param string $user The device user. + * @param string $status A Horde_ActiveSync::RWSTATUS_* constant. + * + * @throws Horde_ActiveSync_Exception + */ + public function setAccountOnlyRWStatus($devId, $user, $status) + { + $query = 'UPDATE ' . $this->_syncUsersTable + . ' SET device_accountonly_rwstatus = ?' + . ' WHERE device_id = ? AND device_user = ?'; + $values = [$status, $devId, $user]; + try { + $this->_db->update($query, $values); + } catch (Horde_Db_Exception $e) { + throw new Horde_ActiveSync_Exception($e); + } + + if ($status == Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING) { + $query = 'UPDATE ' . $this->_syncUsersTable + . ' SET device_policykey = 0 WHERE device_id = ? AND device_user = ?'; + try { + $this->_db->update($query, [$devId, $user]); + } catch (Horde_Db_Exception $e) { + throw new Horde_ActiveSync_Exception($e); + } + } + + if (!empty($this->_deviceInfo) + && $this->_deviceInfo->id == $devId + && $this->_deviceInfo->user == $user) { + $this->_deviceInfo->accountOnlyRwstatus = $status; + } + } + /** * Explicitly remove a state from storage. * diff --git a/migration/Horde/ActiveSync/25_horde_activesync_peruser_accountonly_rwstatus.php b/migration/Horde/ActiveSync/25_horde_activesync_peruser_accountonly_rwstatus.php new file mode 100644 index 00000000..96b2d0e9 --- /dev/null +++ b/migration/Horde/ActiveSync/25_horde_activesync_peruser_accountonly_rwstatus.php @@ -0,0 +1,36 @@ +_connection->columns('horde_activesync_device_users'); + if (!isset($columns['device_accountonly_rwstatus'])) { + $this->addColumn( + 'horde_activesync_device_users', + 'device_accountonly_rwstatus', + 'integer', + ['default' => 0] + ); + } + + // Account-only wipe status was incorrectly stored per device_id. + $this->_connection->update( + 'UPDATE horde_activesync_device SET device_rwstatus = ?' + . ' WHERE device_rwstatus IN (?, ?)', + [ + Horde_ActiveSync::RWSTATUS_OK, + Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING, + Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_WIPED, + ] + ); + } + + public function down() + { + $this->removeColumn( + 'horde_activesync_device_users', + 'device_accountonly_rwstatus' + ); + } +} From 0fc27edb09914b1ff2e9269ac1e9179fccb67507 Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Sun, 21 Jun 2026 22:08:53 +0200 Subject: [PATCH 2/2] fix(activesync): harden account-only wipe state and add unit tests Initialize per-user row data before loadDeviceInfo() reads account-only status, normalize getAccountOnlyRWStatus() to RWSTATUS_NA, reject account-only constants in setDeviceRWStatus(), and add SQLite unit tests for the per-user wipe lifecycle. --- lib/Horde/ActiveSync/State/Base.php | 7 +- lib/Horde/ActiveSync/State/Mongo.php | 7 + lib/Horde/ActiveSync/State/Sql.php | 10 +- .../ActiveSync/AccountOnlyRemoteWipeTest.php | 190 ++++++++++++++++++ 4 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 test/unit/Horde/ActiveSync/AccountOnlyRemoteWipeTest.php diff --git a/lib/Horde/ActiveSync/State/Base.php b/lib/Horde/ActiveSync/State/Base.php index 9a10ba4d..91e76270 100644 --- a/lib/Horde/ActiveSync/State/Base.php +++ b/lib/Horde/ActiveSync/State/Base.php @@ -393,7 +393,8 @@ public function getAccountOnlyRWStatus($devId, $refresh = false) ); } - return $this->_deviceInfo->accountOnlyRwstatus; + return $this->_deviceInfo->accountOnlyRwstatus + ?? Horde_ActiveSync::RWSTATUS_NA; } /** @@ -1342,7 +1343,9 @@ abstract public function resetAllPolicyKeys(); * Set a new remotewipe status for the device * * @param string $devId The device id. - * @param string $status A Horde_ActiveSync::RWSTATUS_* constant. + * @param string $status A device-level Horde_ActiveSync::RWSTATUS_* + * constant. Account-only wipe statuses must be set + * via setAccountOnlyRWStatus(). * * @throws Horde_ActiveSync_Exception */ diff --git a/lib/Horde/ActiveSync/State/Mongo.php b/lib/Horde/ActiveSync/State/Mongo.php index 5d689f84..101c7de0 100644 --- a/lib/Horde/ActiveSync/State/Mongo.php +++ b/lib/Horde/ActiveSync/State/Mongo.php @@ -1351,6 +1351,13 @@ public function resetAllPolicyKeys() */ public function setDeviceRWStatus($devId, $status) { + if ($status == Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING + || $status == Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_WIPED) { + throw new Horde_ActiveSync_Exception( + 'Account-only remote wipe status must be set via setAccountOnlyRWStatus().' + ); + } + $query = [self::MONGO_ID => $devId]; $new_data = [self::DEVICE_RWSTATUS => $status]; $update = ['$set' => $new_data]; diff --git a/lib/Horde/ActiveSync/State/Sql.php b/lib/Horde/ActiveSync/State/Sql.php index 54d63bf5..f780a549 100644 --- a/lib/Horde/ActiveSync/State/Sql.php +++ b/lib/Horde/ActiveSync/State/Sql.php @@ -1173,12 +1173,13 @@ public function loadDeviceInfo($devId, $user = null, $params = []) throw new Horde_ActiveSync_Exception($e); } + $duser = []; if (!empty($user)) { $query = 'SELECT device_policykey, device_accountonly_rwstatus FROM ' . $this->_syncUsersTable . ' WHERE device_id = ? AND device_user = ?'; try { - $duser = $this->_db->selectOne($query, [$devId, $user]); + $duser = $this->_db->selectOne($query, [$devId, $user]) ?: []; } catch (Horde_Db_Exception $e) { throw new Horde_ActiveSync_Exception($e); } @@ -1466,6 +1467,13 @@ public function resetAllPolicyKeys() */ public function setDeviceRWStatus($devId, $status) { + if ($status == Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING + || $status == Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_WIPED) { + throw new Horde_ActiveSync_Exception( + 'Account-only remote wipe status must be set via setAccountOnlyRWStatus().' + ); + } + $query = 'UPDATE ' . $this->_syncDeviceTable . ' SET device_rwstatus = ?' . ' WHERE device_id = ?'; $values = [$status, $devId]; diff --git a/test/unit/Horde/ActiveSync/AccountOnlyRemoteWipeTest.php b/test/unit/Horde/ActiveSync/AccountOnlyRemoteWipeTest.php new file mode 100644 index 00000000..437628fc --- /dev/null +++ b/test/unit/Horde/ActiveSync/AccountOnlyRemoteWipeTest.php @@ -0,0 +1,190 @@ +markTestSkipped('PDO SQLite extension is not loaded'); + } + + $migrationDir = dirname(__DIR__, 4) . '/migration/Horde/ActiveSync'; + $this->db = new Horde_Db_Adapter_Pdo_Sqlite([ + 'dbname' => ':memory:', + 'charset' => 'utf-8', + ]); + + $migrator = new Horde_Db_Migration_Migrator( + $this->db, + new Horde_ActiveSync_Log_Logger(new Horde_Log_Handler_Null()), + [ + 'migrationsPath' => $migrationDir, + 'schemaTableName' => 'horde_activesync_schema_info', + ] + ); + $migrator->up(); + + $this->state = new Horde_ActiveSync_State_Sql(['db' => $this->db]); + } + + public function testLoadDeviceInfoWithoutUserDefaultsAccountOnlyStatusToNa() + { + $this->_saveDeviceUser('dev123', 'alice@example.com'); + + $device = $this->state->loadDeviceInfo('dev123'); + + $this->assertEquals(Horde_ActiveSync::RWSTATUS_NA, $device->accountOnlyRwstatus); + $this->assertEquals(0, $device->policykey); + } + + public function testGetAccountOnlyRWStatusReturnsNaWhenUnset() + { + $this->_saveDeviceUser('dev123', 'alice@example.com'); + $this->state->loadDeviceInfo('dev123'); + + $this->assertEquals( + Horde_ActiveSync::RWSTATUS_NA, + $this->state->getAccountOnlyRWStatus('dev123') + ); + } + + public function testSetAccountOnlyRWStatusPendingClearsOnlyTargetUserPolicyKey() + { + $this->_saveDeviceUser('dev123', 'alice@example.com', 100); + $this->_saveDeviceUser('dev123', 'bob@example.com', 200); + + $this->state->setAccountOnlyRWStatus( + 'dev123', + 'alice@example.com', + Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING + ); + + $alice = $this->state->loadDeviceInfo('dev123', 'alice@example.com'); + $this->assertEquals( + Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING, + $alice->accountOnlyRwstatus + ); + $this->assertEquals(0, $alice->policykey); + + $bob = $this->state->loadDeviceInfo('dev123', 'bob@example.com'); + $this->assertEquals(Horde_ActiveSync::RWSTATUS_NA, $bob->accountOnlyRwstatus); + $this->assertEquals(200, $bob->policykey); + } + + public function testSetAccountOnlyRWStatusWipedPersistsPerUser() + { + $this->_saveDeviceUser('dev123', 'alice@example.com'); + $this->_saveDeviceUser('dev123', 'bob@example.com'); + + $this->state->setAccountOnlyRWStatus( + 'dev123', + 'alice@example.com', + Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_WIPED + ); + + $this->state->loadDeviceInfo('dev123', 'alice@example.com'); + $this->assertEquals( + Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_WIPED, + $this->state->getAccountOnlyRWStatus('dev123', true) + ); + + $bob = $this->state->loadDeviceInfo('dev123', 'bob@example.com'); + $this->assertEquals(Horde_ActiveSync::RWSTATUS_NA, $bob->accountOnlyRwstatus); + } + + public function testSetAccountOnlyRWStatusUpdatesLoadedDeviceCache() + { + $this->_saveDeviceUser('dev123', 'alice@example.com'); + $this->state->loadDeviceInfo('dev123', 'alice@example.com'); + + $this->state->setAccountOnlyRWStatus( + 'dev123', + 'alice@example.com', + Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING + ); + + $this->assertEquals( + Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING, + $this->state->getAccountOnlyRWStatus('dev123') + ); + } + + public function testListDevicesIncludesAccountOnlyRwstatus() + { + $this->_saveDeviceUser('dev123', 'alice@example.com'); + $this->state->setAccountOnlyRWStatus( + 'dev123', + 'alice@example.com', + Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING + ); + + $devices = $this->state->listDevices(); + $this->assertCount(1, $devices); + $this->assertEquals( + Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING, + $devices[0]['device_accountonly_rwstatus'] + ); + } + + public function testSetDeviceRWStatusRejectsAccountOnlyPending() + { + $this->expectException(Horde_ActiveSync_Exception::class); + $this->expectExceptionMessage('setAccountOnlyRWStatus()'); + + $this->state->setDeviceRWStatus( + 'dev123', + Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING + ); + } + + public function testSetDeviceRWStatusRejectsAccountOnlyWiped() + { + $this->expectException(Horde_ActiveSync_Exception::class); + $this->expectExceptionMessage('setAccountOnlyRWStatus()'); + + $this->state->setDeviceRWStatus( + 'dev123', + Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_WIPED + ); + } + + protected function _saveDeviceUser($devId, $user, $policykey = 456) + { + $device = new Horde_ActiveSync_Device($this->state); + $device->rwstatus = Horde_ActiveSync::RWSTATUS_NA; + $device->deviceType = 'Test Device'; + $device->userAgent = 'Horde Tests'; + $device->id = $devId; + $device->user = $user; + $device->policykey = $policykey; + $device->supported = []; + $device->save(); + } +}