diff --git a/.horde.yml b/.horde.yml index 49b4636c..a90976b7 100644 --- a/.horde.yml +++ b/.horde.yml @@ -23,7 +23,7 @@ license: uri: http://www.horde.org/licenses/gpl dependencies: required: - php: ^7.4 || ^8 + php: ^8.1 composer: horde/compress: ^3 horde/date: ^3 @@ -67,16 +67,6 @@ keywords: - eas - exchange ci-platform: - '8.0': - - php: '>=8.1' - - composer-plugin-api: ^2.2.0 - - ext-bcmath - - ext-dom - - ext-hash - - ext-json - - ext-pcre - - ext-simplexml - - ext-spl '8.1': - php: ^8.1 - composer-plugin-api: ^2.2.0 diff --git a/composer.json b/composer.json index 66c6d5ee..2875b130 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "repositories": [], "require": { "horde/horde-installer-plugin": "dev-FRAMEWORK_6_0 || ^3 || ^2", - "php": "^7.4 || ^8", + "php": "^8.1", "horde/compress": "^3 || dev-FRAMEWORK_6_0", "horde/date": "^3 || dev-FRAMEWORK_6_0", "horde/exception": "^3 || dev-FRAMEWORK_6_0", @@ -51,6 +51,9 @@ "autoload": { "psr-0": { "Horde_ActiveSync": "lib/" + }, + "psr-4": { + "Horde\\ActiveSync\\": "src/" } }, "autoload-dev": { diff --git a/doc/ops-monitor.md b/doc/ops-monitor.md new file mode 100644 index 00000000..5af28c60 --- /dev/null +++ b/doc/ops-monitor.md @@ -0,0 +1,170 @@ +# ActiveSync operations monitor + +See the [operations monitor epic](https://github.com/horde/ActiveSync/issues/99). + +## Purpose and scope + +Version 1 provides a shared, read-only health model for answering: + +1. Which devices are active right now? +2. Which sync keys or collections look stuck? +3. Where are loop-guard or heartbeat problems concentrating? +4. How can an operator jump from a bad device to its per-device log and + existing admin actions? + +The monitor does not provide historical metrics, alerting, or webhooks. It +does not add HTTP 503 responses or `X-MS-Throttle`, replace +`logging.type = perdevice`, mutate ActiveSync protocol behaviour, or expose +wipe and block operations through the CLI. + +## Architecture + +| Piece | Package and location | +|-------|----------------------| +| Health heuristics and DTOs | `horde/activesync`, `src/Ops` | +| Snapshot service | `horde/core` | +| CLI `horde-activesync summary\|show\|top` | `horde/horde` (the `horde/base` repository) | +| Admin health badges | `horde/horde` (the `horde/base` repository) | + +The evaluator consumes plain facts and has no storage dependency. +`DeviceHealthFactory` is the compatibility boundary that converts legacy +ActiveSync device, cache, and state rows into those facts. The snapshot +service loads fleet state, and both operator interfaces consume its shared +results. + +A device's collections are a `CollectionFactList`: an immutable map of +`CollectionFacts`, keyed by collection id and readable with array access. +Each fact copies `class`, `serverid`, `lastsynckey`, `backlog`, +`backlogpings`, and `pingable` from the SyncCache row. Sync options such as +window size, filter type, and body preferences stay on the cache row. + +## Health model + +Device and collection status is one of `ok`, `warn`, or `critical`. The worst +signal determines collection status, and the worst device or collection +status determines device status. + +A device is active when a heartbeat is inferred to be in flight, or when its +latest SyncCache or last-sync timestamp is no older than `activeWithin`. +A device is stuck when it has `hb_stuck`, `hb_missing_end`, `fsr_critical`, or +`backlog_stuck` on either the device or one of its collections. + +| Tuning option | Default | Meaning | +|---------------|---------|---------| +| `now` | current time | Evaluation timestamp; fixed values make tests and snapshots deterministic | +| `activeWithin` | 300 seconds | Maximum state age counted as active | +| `hbStuckAfter` | derived | Explicit heartbeat-stuck threshold, when set | +| `hbDefaultInterval` | 900 seconds | Heartbeat interval used when the cache has none | +| `hbSlack` | 60 seconds | Allowance added to the heartbeat interval | +| `hbAbandonedAfter` | 2 × heartbeat-stuck threshold | Age after which an unfinished heartbeat is treated as abandoned rather than stuck | +| `fsrWarnAt` | 3 | FolderSync-required warning threshold | +| `fsrCriticalAt` | 5 | FolderSync-required critical threshold | +| `backlogGrace` | 60 seconds | Minimum backlog age before a pending warning | +| `backlogTriggerMax` | 3 | Backlog recovery count considered stuck | +| `backlogAbandonedAfter` | 3600 seconds | Age after which a capped backlog is treated as abandoned rather than stuck | + +When `hbStuckAfter` is unset, the threshold is the cached heartbeat interval +(or `hbDefaultInterval`) plus `hbSlack`. + +## Reason codes + +| Code | Condition | Severity | What to do | +|------|-----------|----------|------------| +| `hb_in_flight` | Heartbeat start is newer than its end, or no end is recorded, and its age is at most the stuck threshold | `ok` | No action; this is activity context | +| `hb_stuck` | Heartbeat start is newer than a recorded end and its age is between the stuck threshold and the abandoned threshold | `warn` | Check whether the client recently disconnected, suspended radio activity, rebooted, or updated; inspect the device log if the signal persists | +| `hb_missing_end` | A heartbeat start has no recorded end and its age is between the stuck threshold and the abandoned threshold | `warn` | Check for a transient client disconnect; inspect the device log if the signal persists | +| `hb_abandoned` | An unfinished heartbeat is older than the abandoned threshold | `ok` | Nothing — common after client disconnects; if it repeats for an otherwise active device, check the device log | +| `fsr_warn` | FolderSync-required count is at least 3 and below 5 by default | `warn` | Check whether the client completes a FolderSync cycle | +| `fsr_critical` | FolderSync-required count is at least 5 by default | `critical` | Inspect hierarchy state and the device log. The counter clears on FolderSync, or on a later successful PING/SYNC that does not request FolderSync (after ghost-collection heal) | +| `blocked` | Device properties mark the device blocked | `warn` | Confirm the block is intentional in the admin device page | +| `wipe_pending` | Full or account-only remote wipe is pending | `warn` | Confirm provisioning reaches the device | +| `wipe_complete` | Full or account-only remote wipe is recorded complete | `warn` | Confirm the completed state is expected | +| `backlog_pending` | Backlog age reached the grace period and recovery count remains below the trigger maximum | `warn` | Check whether the client drains subsequent Sync windows | +| `backlog_stuck` | A backlog exists, recovery count reached the trigger maximum, and the MOREAVAILABLE is still newer than `backlogAbandonedAfter` | `critical` | Inspect collection sync keys and the per-device log | + +The numeric conditions use the configured thresholds rather than fixed +defaults when options are overridden. + +## Honest limits + +Version 1 is derived from currently persisted device and SyncCache state; it +cannot prove that a PHP request is still running. An unfinished heartbeat is +therefore a warning rather than critical: a radio gap, client update, reboot, +or suspended app can leave the same persisted timestamps as a server-side +problem. An old unfinished heartbeat is treated as abandoned, not stuck, +because a PHP request cannot plausibly remain active beyond that window. +The initial fleet snapshot costs one +SyncCache load per device; it is not a constant-cost aggregate query. +Per-device protocol logs remain the deep-dive source. + +## Cookbook (CLI + admin) + +### Triage active devices + +Start with active devices ordered by health and keep the display refreshed: + +```sh +horde-activesync top --watch --active-within=300 --sort=health +``` + +Narrow a large fleet with `--user`, `--device`, `--health`, or +`--stuck-only`, and cap output with `--limit`. Use `--format` when another +tool consumes the result. The snapshot performs one SyncCache read per device +considered after filtering; it is not a constant-cost fleet aggregate. + +### Run a cron or Nagios check + +```sh +horde-activesync summary --format=json +``` + +The command exits with `0` when no critical device is present, `1` when at +least one critical device is present, and `2` when monitoring data is +unavailable. Cron and Nagios wrappers should preserve that exit status and +retain the JSON output for diagnosis. + +### Inspect one device + +Use the exact user and device identifiers shown by `top` or `summary`: + +```sh +horde-activesync show alice@example.com DEVICE1 +``` + +When `logging.type = perdevice`, the protocol log is: + +```text +{configured logging path}/{UPPERCASE DEVICE ID}.txt +``` + +For example, device `DEVICE1` maps to `DEVICE1.txt` beneath the configured +logging path. The path comes from the deployment configuration; do not embed +credentials or deployment-specific paths in scripts or reports. + +### Confirm and act in the admin page + +Open **Administration → ActiveSync Devices**, find the same user and device, +and verify the health signal against the device details and per-device log. +Only then use the existing wipe, block, remove, or reset actions, with care: +they change device state and can disrupt or erase client data. The +`horde-activesync` CLI has no mutation commands. + +### Interpret common signals + +- `hb_stuck` and `hb_missing_end` are warnings for an unfinished heartbeat + still inside the plausible stuck window. Transient client disconnects, + radio gaps, reboots, and updates commonly produce this state; inspect the + request lifecycle and device log only when it persists or repeats. + `hb_abandoned` is older than that window and is intentionally `ok` + because the original request can no longer plausibly be running. Repeated + abandoned heartbeats on an otherwise active device still merit log review. +- `backlog_stuck` means a collection has repeatedly failed to drain pending + changes *and* that MOREAVAILABLE is still recent. After + `backlogAbandonedAfter` (or after PING/looping-SYNC recovery hits its + trigger cap and clears the flag), an undrained folder is no longer + fleet-critical: leftover `sync_pending` waits for the next real SYNC, which + some clients never send for folders they only PING (for example iOS Mail + and Trash). Inspect sync keys and the log only while the signal is present. + `fsr_warn` / `fsr_critical` instead mean repeated FolderSync-required + recovery (FSR). After ghost collections are healed, a later successful + PING/SYNC clears that counter even if the client never FolderSyncs again. diff --git a/doc/todo.md b/doc/todo.md index b286928e..aa797414 100644 --- a/doc/todo.md +++ b/doc/todo.md @@ -216,6 +216,37 @@ when work proceeds on FRAMEWORK_6_1. - `Horde_ActiveSync_Sync_Options` (and similar) for collection options / body preferences instead of raw arrays. - Collection object replacing the associative collection array in `Sync.php`. + The array carries ~34 keys across ~440 read sites (`Request/Sync.php` 181, + `SyncCache.php` 138, `Collections.php` 63) and mixes four concerns: + identity (`id`, `class`, `serverid`, `type`), persisted sync state + (`synckey`, `newsynckey`, `lastsynckey`, `getchanges`, `pingable`, + `backlog`, `backlogpings`), client-negotiated options (`windowsize`, + `filtertype`, `truncation`, `mimesupport`, `bodyprefs`, `conflict`, + `deletesasmoves`, `conversationmode`, …), and per-request scratch + (`clientids`, `fetchids`, `removes`, `modifiedids`, `importedchanges`, + `importfailures`, `atchash`, `instanceid_removes`, …). Target shape: + - `SyncCollection` for identity plus persisted sync state, with + `Horde_ActiveSync_SyncKey` as a first-class type (see *State, storage, + and identity* above); `Sync_Options` for the negotiated options; the + Changes object below for per-request scratch. Do not fold all four into + one interface. + - Storage stays an array. `SyncCache` hydrates rows into objects on load + and dehydrates on save; never `serialize()` the objects into + `cache_data`, which would change the blob format. + - `ArrayAccess` on the concrete `SyncCollection` class is a migration + shim so `$collection['class']` keeps working in `Sync.php`, ideally + logging deprecated access. Remove it once `Sync.php` is converted. Keep + it off the interface so the array API does not become permanent. + - Order: `SyncCache::addCollection()` first (it defines the persisted + fields), then `Collections.php`, then `Sync.php` (hot protocol path, + largest, riskiest). + - `Horde\ActiveSync\Ops\CollectionFacts` (health monitor, #100) is a + seven-field read-only projection built in + `DeviceHealthFactory::collectionFacts()`, the only place that converts + cache rows. Once `SyncCollection` exists, build the projection from it + or let `HealthEvaluator` consume `SyncCollection` directly; then drop + `CollectionFacts`. It is not the domain object and must not grow into + one. - Changes object (array, `SplFixedArray`, or temp stream) to cap memory on large initial mailbox syncs and to unify the `add` / `modify` / `delete` shapes between email and PIM collections. Streaming v1 ([#83](https://github.com/horde/ActiveSync/issues/83)) diff --git a/lib/Horde/ActiveSync.php b/lib/Horde/ActiveSync.php index 5c6cdb96..0c68db19 100644 --- a/lib/Horde/ActiveSync.php +++ b/lib/Horde/ActiveSync.php @@ -789,7 +789,7 @@ public function handleRequest($cmd, $devId) // Autodiscovery handles authentication on it's own. if ($cmd == 'Autodiscover') { - $request = new Horde_ActiveSync_Request_Autodiscover($this, new Horde_ActiveSync_Device($this->_state)); + $request = new Horde_ActiveSync_Request_Autodiscover($this); if (!empty(self::$_logger)) { $request->setLogger(self::$_logger); diff --git a/lib/Horde/ActiveSync/Request/Base.php b/lib/Horde/ActiveSync/Request/Base.php index 211200e8..2d7c38ac 100644 --- a/lib/Horde/ActiveSync/Request/Base.php +++ b/lib/Horde/ActiveSync/Request/Base.php @@ -106,8 +106,8 @@ abstract class Horde_ActiveSync_Request_Base /** * Const'r * - * @param Horde_ActiveSync $as The ActiveSync server. - * @param Horde_ActiveSync_Device $device The device descriptor. + * @param Horde_ActiveSync $as The ActiveSync server. The device + * descriptor is read from the server. * * @return Horde_ActiveSync_Request_Base */ diff --git a/migration/Horde/ActiveSync/20_horde_activesync_removesynccounters.php b/migration/Horde/ActiveSync/20_horde_activesync_removesynccounters.php index e20ee47f..773c0cf7 100644 --- a/migration/Horde/ActiveSync/20_horde_activesync_removesynccounters.php +++ b/migration/Horde/ActiveSync/20_horde_activesync_removesynccounters.php @@ -4,7 +4,7 @@ class HordeActiveSyncRemoveSyncCounters extends Horde_Db_Migration_Base { public function up() { - $this->announce('Removing SyncKeyCounter data from cache.', 'cli.message'); + $this->announce('Removing SyncKeyCounter data from cache.'); $sql = 'SELECT * FROM horde_activesync_cache'; $rows = $this->_connection->select($sql); $insert_sql = 'UPDATE horde_activesync_cache SET cache_data = ? WHERE cache_devid = ? AND cache_user = ?'; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 3e12baf3..62d47947 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -15,6 +15,7 @@ lib + src diff --git a/src/Ops/CollectionFactList.php b/src/Ops/CollectionFactList.php new file mode 100644 index 00000000..dabf5a41 --- /dev/null +++ b/src/Ops/CollectionFactList.php @@ -0,0 +1,107 @@ + + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/gpl GPLv2 + * @package ActiveSync + */ + +namespace Horde\ActiveSync\Ops; + +use ArrayAccess; +use ArrayIterator; +use BadMethodCallException; +use Countable; +use InvalidArgumentException; +use IteratorAggregate; +use OutOfBoundsException; +use Traversable; + +/** + * Immutable collection-id map of SyncCache health snapshots. + * + * @implements ArrayAccess + * @implements IteratorAggregate + */ +final class CollectionFactList implements ArrayAccess, Countable, IteratorAggregate +{ + /** + * @var array + */ + private array $byId; + + /** + * @param iterable $collections + */ + public function __construct(iterable $collections = []) + { + $byId = []; + foreach ($collections as $collection) { + if (!$collection instanceof CollectionFacts) { + throw new InvalidArgumentException( + 'Collection fact lists accept CollectionFacts entries only.' + ); + } + $byId[$collection->id] = $collection; + } + $this->byId = $byId; + } + + public function get(string $id): ?CollectionFacts + { + return $this->byId[$id] ?? null; + } + + /** + * @return list + */ + public function all(): array + { + return array_values($this->byId); + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->all()); + } + + public function count(): int + { + return count($this->byId); + } + + public function offsetExists(mixed $offset): bool + { + return is_string($offset) && isset($this->byId[$offset]); + } + + public function offsetGet(mixed $offset): CollectionFacts + { + if (!is_string($offset) || !isset($this->byId[$offset])) { + throw new OutOfBoundsException( + 'Collection fact list has no entry for this id.' + ); + } + + return $this->byId[$offset]; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new BadMethodCallException('Collection fact lists are immutable.'); + } + + public function offsetUnset(mixed $offset): void + { + throw new BadMethodCallException('Collection fact lists are immutable.'); + } +} diff --git a/src/Ops/CollectionFacts.php b/src/Ops/CollectionFacts.php new file mode 100644 index 00000000..66089586 --- /dev/null +++ b/src/Ops/CollectionFacts.php @@ -0,0 +1,50 @@ + + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/gpl GPLv2 + * @package ActiveSync + */ + +namespace Horde\ActiveSync\Ops; + +/** + * Health inputs copied from one persisted SyncCache collection row. + * + * The cache stores collections as an associative array keyed by collection + * id. This snapshot keeps the fields the evaluator reads: + * + * - id: collection id, from the row key or the row's id value + * - class: EAS collection class (`class`) + * - serverid: backend folder id (`serverid`) + * - lastsynckey: persisted sync key (`lastsynckey`; the in-request + * `synckey` alias is ignored) + * - backlog: unix time when MOREAVAILABLE was recorded (`backlog`) + * - backlogpings: recovery attempts (`backlogpings`) + * - pingable: whether the collection is included in PING (`pingable`) + * + * Window size, filter type, truncation, body preferences, and conflict + * policy stay on the cache row. + */ +final class CollectionFacts +{ + public function __construct( + public readonly string $id, + public readonly ?string $class = null, + public readonly ?string $serverid = null, + public readonly ?string $lastsynckey = null, + public readonly ?int $backlog = null, + public readonly int $backlogpings = 0, + public readonly bool $pingable = false + ) { + } +} diff --git a/src/Ops/CollectionHealth.php b/src/Ops/CollectionHealth.php new file mode 100644 index 00000000..349b7ec8 --- /dev/null +++ b/src/Ops/CollectionHealth.php @@ -0,0 +1,49 @@ + + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/gpl GPLv2 + * @package ActiveSync + */ + +namespace Horde\ActiveSync\Ops; + +final class CollectionHealth +{ + /** + * @param HealthSignal[] $signals + */ + public function __construct( + public readonly string $id, + public readonly ?string $class, + public readonly ?string $serverid, + public readonly ?string $lastsynckey, + public readonly string $status, + public readonly array $signals + ) { + } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'class' => $this->class, + 'serverid' => $this->serverid, + 'lastsynckey' => $this->lastsynckey, + 'status' => $this->status, + 'signals' => array_map( + static fn (HealthSignal $signal): array => $signal->toArray(), + $this->signals + ), + ]; + } +} diff --git a/src/Ops/DeviceFacts.php b/src/Ops/DeviceFacts.php new file mode 100644 index 00000000..197b205d --- /dev/null +++ b/src/Ops/DeviceFacts.php @@ -0,0 +1,42 @@ + + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/gpl GPLv2 + * @package ActiveSync + */ + +namespace Horde\ActiveSync\Ops; + +final class DeviceFacts +{ + /** + * @param CollectionFactList $collections SyncCache snapshots keyed by collection id + */ + public function __construct( + public readonly string $user, + public readonly string $deviceId, + public readonly string $deviceType, + public readonly ?string $version, + public readonly int $rwstatus, + public readonly int $accountOnlyRwstatus, + public readonly bool $blocked, + public readonly ?int $lastSyncTs, + public readonly ?int $cacheTimestamp, + public readonly ?int $hbinterval, + public readonly ?int $lasthbsyncstarted, + public readonly ?int $lastsyncendnormal, + public readonly int $foldersyncrequired, + public readonly CollectionFactList $collections + ) { + } +} diff --git a/src/Ops/DeviceHealth.php b/src/Ops/DeviceHealth.php new file mode 100644 index 00000000..4677cc21 --- /dev/null +++ b/src/Ops/DeviceHealth.php @@ -0,0 +1,98 @@ + + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/gpl GPLv2 + * @package ActiveSync + */ + +namespace Horde\ActiveSync\Ops; + +final class DeviceHealth +{ + /** + * @param HealthSignal[] $signals + * @param CollectionHealth[] $collections + */ + public function __construct( + public readonly string $user, + public readonly string $deviceId, + public readonly string $deviceType, + public readonly ?string $version, + public readonly string $status, + public readonly bool $active, + public readonly ?int $ageSeconds, + public readonly ?int $hbinterval, + public readonly int $foldersyncrequired, + public readonly array $signals, + public readonly array $collections, + public ?string $logPath = null + ) { + } + + public function withLogPath(?string $logPath): self + { + $clone = clone $this; + $clone->logPath = $logPath; + + return $clone; + } + + public function isStuck(): bool + { + return array_intersect($this->signalCodes(), [ + SignalCode::HB_STUCK, + SignalCode::HB_MISSING_END, + SignalCode::FSR_CRITICAL, + SignalCode::BACKLOG_STUCK, + ]) !== []; + } + + public function signalCodes(): array + { + $codes = array_map( + static fn (HealthSignal $signal): string => $signal->code, + $this->signals + ); + foreach ($this->collections as $collection) { + foreach ($collection->signals as $signal) { + $codes[] = $signal->code; + } + } + + return array_values(array_unique($codes)); + } + + public function toArray(): array + { + return [ + 'user' => $this->user, + 'deviceId' => $this->deviceId, + 'deviceType' => $this->deviceType, + 'version' => $this->version, + 'status' => $this->status, + 'active' => $this->active, + 'ageSeconds' => $this->ageSeconds, + 'hbinterval' => $this->hbinterval, + 'foldersyncrequired' => $this->foldersyncrequired, + 'signals' => array_map( + static fn (HealthSignal $signal): array => $signal->toArray(), + $this->signals + ), + 'collections' => array_map( + static fn (CollectionHealth $collection): array => $collection->toArray(), + $this->collections + ), + 'logPath' => $this->logPath, + ]; + } +} diff --git a/src/Ops/DeviceHealthFactory.php b/src/Ops/DeviceHealthFactory.php new file mode 100644 index 00000000..02e900ed --- /dev/null +++ b/src/Ops/DeviceHealthFactory.php @@ -0,0 +1,164 @@ + + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/gpl GPLv2 + * @package ActiveSync + */ + +namespace Horde\ActiveSync\Ops; + +use Horde_ActiveSync_Device; +use Horde_ActiveSync_SyncCache; + +final class DeviceHealthFactory +{ + private readonly HealthEvaluator $evaluator; + + public function __construct(?HealthEvaluator $evaluator = null) + { + $this->evaluator = $evaluator ?? new HealthEvaluator(); + } + + public function fromDevice( + Horde_ActiveSync_Device $device, + Horde_ActiveSync_SyncCache $cache, + ?int $lastSyncTs = null + ): DeviceHealth { + $properties = is_array($device->properties) + ? $device->properties + : []; + $hbinterval = self::nullableInt($cache->hbinterval); + $wait = self::nullableInt($cache->wait); + if ($hbinterval === null && $wait !== null) { + $hbinterval = $wait * 60; + } + + return $this->evaluator->evaluate(new DeviceFacts( + user: (string) $device->user, + deviceId: (string) $device->id, + deviceType: (string) $device->deviceType, + version: self::nullableString($properties['version'] ?? null), + rwstatus: (int) $device->rwstatus, + accountOnlyRwstatus: (int) $device->accountOnlyRwstatus, + blocked: !empty($properties['blocked']), + lastSyncTs: $lastSyncTs, + cacheTimestamp: self::nullableInt($cache->timestamp), + hbinterval: $hbinterval, + lasthbsyncstarted: self::nullableInt($cache->lasthbsyncstarted), + lastsyncendnormal: self::nullableInt($cache->lastsyncendnormal), + foldersyncrequired: $cache->getFolderSyncRequiredIgnoredCount(), + collections: self::collectionFacts($cache->getCollections(false)) + )); + } + + public function fromRow( + array $deviceRow, + array $cacheData, + ?int $lastSyncTs = null + ): DeviceHealth { + $properties = self::properties($deviceRow['device_properties'] ?? []); + $hbinterval = self::nullableInt($cacheData['hbinterval'] ?? null); + $wait = self::nullableInt($cacheData['wait'] ?? null); + if ($hbinterval === null && $wait !== null) { + $hbinterval = $wait * 60; + } + + return $this->evaluator->evaluate(new DeviceFacts( + user: (string) ($deviceRow['device_user'] ?? ''), + deviceId: (string) ($deviceRow['device_id'] ?? ''), + deviceType: (string) ($deviceRow['device_type'] ?? ''), + version: self::nullableString($properties['version'] ?? null), + rwstatus: (int) ($deviceRow['device_rwstatus'] ?? 0), + accountOnlyRwstatus: + (int) ($deviceRow['device_accountonly_rwstatus'] ?? 0), + blocked: !empty($properties['blocked']), + lastSyncTs: $lastSyncTs, + cacheTimestamp: self::nullableInt($cacheData['timestamp'] ?? null), + hbinterval: $hbinterval, + lasthbsyncstarted: + self::nullableInt($cacheData['lasthbsyncstarted'] ?? null), + lastsyncendnormal: + self::nullableInt($cacheData['lastsyncendnormal'] ?? null), + foldersyncrequired: + (int) ($cacheData['foldersyncrequired'] ?? 0), + collections: self::collectionFacts( + is_array($cacheData['collections'] ?? null) + ? $cacheData['collections'] + : [] + ) + )); + } + + private static function properties(mixed $properties): array + { + if (is_array($properties)) { + return $properties; + } + if (!is_string($properties) || $properties === '') { + return []; + } + + $decoded = @unserialize( + $properties, + ['allowed_classes' => false] + ); + + return is_array($decoded) ? $decoded : []; + } + + /** + * Persisted SyncCache rows, keyed by collection id. + * + * Copied keys are id (or the row key), class, serverid, lastsynckey, + * backlog, backlogpings, and pingable. + * + * @param array> $collections + */ + private static function collectionFacts(array $collections): CollectionFactList + { + $facts = []; + foreach ($collections as $id => $collection) { + if (!is_array($collection)) { + continue; + } + $collectionId = (string) ($collection['id'] ?? $id); + $facts[] = new CollectionFacts( + id: $collectionId, + class: self::nullableString($collection['class'] ?? null), + serverid: + self::nullableString($collection['serverid'] ?? null), + lastsynckey: + self::nullableString($collection['lastsynckey'] ?? null), + backlog: self::nullableInt($collection['backlog'] ?? null), + backlogpings: (int) ($collection['backlogpings'] ?? 0), + pingable: !empty($collection['pingable']) + ); + } + + return new CollectionFactList($facts); + } + + private static function nullableInt(mixed $value): ?int + { + return $value === null || $value === false || $value === '' + ? null + : (int) $value; + } + + private static function nullableString(mixed $value): ?string + { + return $value === null || $value === false || $value === '' + ? null + : (string) $value; + } +} diff --git a/src/Ops/FleetSummary.php b/src/Ops/FleetSummary.php new file mode 100644 index 00000000..9fc67c82 --- /dev/null +++ b/src/Ops/FleetSummary.php @@ -0,0 +1,98 @@ + + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/gpl GPLv2 + * @package ActiveSync + */ + +namespace Horde\ActiveSync\Ops; + +final class FleetSummary +{ + public function __construct( + public readonly int $devices, + public readonly int $active, + public readonly int $ok, + public readonly int $warn, + public readonly int $critical, + public readonly int $stuck, + public readonly int $wipePending, + public readonly int $blocked, + public readonly int $asOf + ) { + } + + public static function fromDevices(iterable $deviceHealths, int $asOf): self + { + $counts = [ + 'devices' => 0, + 'active' => 0, + 'ok' => 0, + 'warn' => 0, + 'critical' => 0, + 'stuck' => 0, + 'wipePending' => 0, + 'blocked' => 0, + ]; + + foreach ($deviceHealths as $device) { + ++$counts['devices']; + $counts['active'] += (int) $device->active; + ++$counts[$device->status]; + $counts['stuck'] += (int) $device->isStuck(); + $codes = $device->signalCodes(); + $counts['wipePending'] += (int) in_array( + SignalCode::WIPE_PENDING, + $codes, + true + ); + $counts['blocked'] += (int) in_array( + SignalCode::BLOCKED, + $codes, + true + ); + } + + return new self( + devices: $counts['devices'], + active: $counts['active'], + ok: $counts['ok'], + warn: $counts['warn'], + critical: $counts['critical'], + stuck: $counts['stuck'], + wipePending: $counts['wipePending'], + blocked: $counts['blocked'], + asOf: $asOf + ); + } + + public function hasCritical(): bool + { + return $this->critical > 0; + } + + public function toArray(): array + { + return [ + 'devices' => $this->devices, + 'active' => $this->active, + 'ok' => $this->ok, + 'warn' => $this->warn, + 'critical' => $this->critical, + 'stuck' => $this->stuck, + 'wipePending' => $this->wipePending, + 'blocked' => $this->blocked, + 'asOf' => $this->asOf, + ]; + } +} diff --git a/src/Ops/HealthEvaluator.php b/src/Ops/HealthEvaluator.php new file mode 100644 index 00000000..42805500 --- /dev/null +++ b/src/Ops/HealthEvaluator.php @@ -0,0 +1,197 @@ + + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/gpl GPLv2 + * @package ActiveSync + */ + +namespace Horde\ActiveSync\Ops; + +use Horde_ActiveSync; + +final class HealthEvaluator +{ + private readonly HealthOptions $options; + + public function __construct(?HealthOptions $options = null) + { + $this->options = $options ?? new HealthOptions(); + } + + public function evaluate(DeviceFacts $facts): DeviceHealth + { + $now = $this->options->now(); + $stuckAfter = $this->options->hbStuckAfterFor($facts->hbinterval); + $abandonedAfter = $this->options->hbAbandonedAfterFor( + $facts->hbinterval + ); + $signals = []; + $started = $facts->lasthbsyncstarted ?: null; + $ended = $facts->lastsyncendnormal ?: null; + $heartbeatInFlight = false; + + if ($started !== null && ($ended === null || $started > $ended)) { + $heartbeatAge = $now - $started; + if ($heartbeatAge <= $stuckAfter) { + $signals[] = $this->signal( + SignalCode::HB_IN_FLIGHT, + HealthStatus::OK + ); + $heartbeatInFlight = true; + } elseif ($heartbeatAge <= $abandonedAfter && $ended === null) { + $signals[] = $this->signal( + SignalCode::HB_MISSING_END, + HealthStatus::WARN + ); + } elseif ($heartbeatAge <= $abandonedAfter) { + $signals[] = $this->signal( + SignalCode::HB_STUCK, + HealthStatus::WARN + ); + } else { + $signals[] = $this->signal( + SignalCode::HB_ABANDONED, + HealthStatus::OK + ); + } + } + + if ($facts->foldersyncrequired >= $this->options->fsrCriticalAt) { + $signals[] = $this->signal( + SignalCode::FSR_CRITICAL, + HealthStatus::CRITICAL + ); + } elseif ($facts->foldersyncrequired >= $this->options->fsrWarnAt) { + $signals[] = $this->signal( + SignalCode::FSR_WARN, + HealthStatus::WARN + ); + } + + if ($facts->blocked) { + $signals[] = $this->signal(SignalCode::BLOCKED, HealthStatus::WARN); + } + + $pending = [ + Horde_ActiveSync::RWSTATUS_PENDING, + Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING, + ]; + $wiped = [ + Horde_ActiveSync::RWSTATUS_WIPED, + Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_WIPED, + ]; + if (in_array($facts->rwstatus, $pending, true) + || $facts->accountOnlyRwstatus === Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING) { + $signals[] = $this->signal( + SignalCode::WIPE_PENDING, + HealthStatus::WARN + ); + } + if (in_array($facts->rwstatus, $wiped, true) + || $facts->accountOnlyRwstatus === Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_WIPED) { + $signals[] = $this->signal( + SignalCode::WIPE_COMPLETE, + HealthStatus::WARN + ); + } + + $collections = []; + foreach ($facts->collections as $collection) { + $collections[] = $this->evaluateCollection($collection, $now); + } + + $activityTimestamps = array_filter( + [$facts->cacheTimestamp, $facts->lastSyncTs], + static fn (?int $timestamp): bool => $timestamp !== null + ); + $ageSeconds = $activityTimestamps === [] + ? null + : $now - max($activityTimestamps); + $active = $heartbeatInFlight + || ($ageSeconds !== null + && $ageSeconds <= $this->options->activeWithin); + + $statuses = array_map( + static fn (HealthSignal $signal): string => $signal->severity, + $signals + ); + foreach ($collections as $collection) { + $statuses[] = $collection->status; + } + + return new DeviceHealth( + user: $facts->user, + deviceId: $facts->deviceId, + deviceType: $facts->deviceType, + version: $facts->version, + status: HealthStatus::worst(...$statuses), + active: $active, + ageSeconds: $ageSeconds, + hbinterval: $facts->hbinterval, + foldersyncrequired: $facts->foldersyncrequired, + signals: $signals, + collections: $collections + ); + } + + private function evaluateCollection( + CollectionFacts $facts, + int $now + ): CollectionHealth { + $signals = []; + if ($facts->backlog !== null + && $facts->backlogpings >= $this->options->backlogTriggerMax + && ($now - $facts->backlog) < $this->options->backlogAbandonedAfter) { + $signals[] = $this->signal( + SignalCode::BACKLOG_STUCK, + HealthStatus::CRITICAL, + $facts->id + ); + } elseif ($facts->backlog !== null + && ($now - $facts->backlog) >= $this->options->backlogGrace + && $facts->backlogpings < $this->options->backlogTriggerMax) { + $signals[] = $this->signal( + SignalCode::BACKLOG_PENDING, + HealthStatus::WARN, + $facts->id + ); + } + + $statuses = array_map( + static fn (HealthSignal $signal): string => $signal->severity, + $signals + ); + + return new CollectionHealth( + id: $facts->id, + class: $facts->class, + serverid: $facts->serverid, + lastsynckey: $facts->lastsynckey, + status: HealthStatus::worst(...$statuses), + signals: $signals + ); + } + + private function signal( + string $code, + string $severity, + ?string $collectionId = null + ): HealthSignal { + return new HealthSignal( + code: $code, + severity: $severity, + detail: SignalCode::describe($code), + collectionId: $collectionId + ); + } +} diff --git a/src/Ops/HealthOptions.php b/src/Ops/HealthOptions.php new file mode 100644 index 00000000..a6a5d142 --- /dev/null +++ b/src/Ops/HealthOptions.php @@ -0,0 +1,53 @@ + + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/gpl GPLv2 + * @package ActiveSync + */ + +namespace Horde\ActiveSync\Ops; + +final class HealthOptions +{ + public function __construct( + private readonly ?int $now = null, + public readonly int $activeWithin = 300, + public readonly ?int $hbStuckAfter = null, + public readonly int $hbDefaultInterval = 900, + public readonly int $hbSlack = 60, + public readonly ?int $hbAbandonedAfter = null, + public readonly int $fsrWarnAt = 3, + public readonly int $fsrCriticalAt = 5, + public readonly int $backlogGrace = 60, + public readonly int $backlogTriggerMax = 3, + public readonly int $backlogAbandonedAfter = 3600 + ) { + } + + public function now(): int + { + return $this->now ?? time(); + } + + public function hbStuckAfterFor(?int $hbinterval): int + { + return $this->hbStuckAfter + ?? (($hbinterval ?: $this->hbDefaultInterval) + $this->hbSlack); + } + + public function hbAbandonedAfterFor(?int $hbinterval): int + { + return $this->hbAbandonedAfter + ?? (2 * $this->hbStuckAfterFor($hbinterval)); + } +} diff --git a/src/Ops/HealthSignal.php b/src/Ops/HealthSignal.php new file mode 100644 index 00000000..1b648bff --- /dev/null +++ b/src/Ops/HealthSignal.php @@ -0,0 +1,39 @@ + + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/gpl GPLv2 + * @package ActiveSync + */ + +namespace Horde\ActiveSync\Ops; + +final class HealthSignal +{ + public function __construct( + public readonly string $code, + public readonly string $severity, + public readonly string $detail, + public readonly ?string $collectionId = null + ) { + } + + public function toArray(): array + { + return [ + 'code' => $this->code, + 'severity' => $this->severity, + 'detail' => $this->detail, + 'collectionId' => $this->collectionId, + ]; + } +} diff --git a/src/Ops/HealthStatus.php b/src/Ops/HealthStatus.php new file mode 100644 index 00000000..cf05e647 --- /dev/null +++ b/src/Ops/HealthStatus.php @@ -0,0 +1,56 @@ + + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/gpl GPLv2 + * @package ActiveSync + */ + +namespace Horde\ActiveSync\Ops; + +use InvalidArgumentException; + +final class HealthStatus +{ + public const OK = 'ok'; + public const WARN = 'warn'; + public const CRITICAL = 'critical'; + + public static function rank(string $status): int + { + return match ($status) { + self::OK => 0, + self::WARN => 1, + self::CRITICAL => 2, + default => throw new InvalidArgumentException( + sprintf('Unknown health status: %s', $status) + ), + }; + } + + public static function worst(string ...$statuses): string + { + $worst = self::OK; + foreach ($statuses as $status) { + if (self::rank($status) > self::rank($worst)) { + $worst = $status; + } + } + + return $worst; + } + + public static function isValid(string $status): bool + { + return in_array($status, [self::OK, self::WARN, self::CRITICAL], true); + } +} diff --git a/src/Ops/SignalCode.php b/src/Ops/SignalCode.php new file mode 100644 index 00000000..5aebb7de --- /dev/null +++ b/src/Ops/SignalCode.php @@ -0,0 +1,72 @@ + + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/gpl GPLv2 + * @package ActiveSync + */ + +namespace Horde\ActiveSync\Ops; + +use InvalidArgumentException; + +final class SignalCode +{ + public const HB_IN_FLIGHT = 'hb_in_flight'; + public const HB_STUCK = 'hb_stuck'; + public const HB_MISSING_END = 'hb_missing_end'; + public const HB_ABANDONED = 'hb_abandoned'; + public const FSR_WARN = 'fsr_warn'; + public const FSR_CRITICAL = 'fsr_critical'; + public const BLOCKED = 'blocked'; + public const WIPE_PENDING = 'wipe_pending'; + public const WIPE_COMPLETE = 'wipe_complete'; + public const BACKLOG_PENDING = 'backlog_pending'; + public const BACKLOG_STUCK = 'backlog_stuck'; + + public static function all(): array + { + return [ + self::HB_IN_FLIGHT, + self::HB_STUCK, + self::HB_MISSING_END, + self::HB_ABANDONED, + self::FSR_WARN, + self::FSR_CRITICAL, + self::BLOCKED, + self::WIPE_PENDING, + self::WIPE_COMPLETE, + self::BACKLOG_PENDING, + self::BACKLOG_STUCK, + ]; + } + + public static function describe(string $code): string + { + return match ($code) { + self::HB_IN_FLIGHT => 'Heartbeat sync is currently in flight.', + self::HB_STUCK => 'Heartbeat sync has exceeded its expected duration.', + self::HB_MISSING_END => 'Heartbeat sync has no recorded normal end.', + self::HB_ABANDONED => 'Last heartbeat sync ended abnormally; the request is no longer running.', + self::FSR_WARN => 'Repeated FolderSync-required responses are approaching the limit.', + self::FSR_CRITICAL => 'Repeated FolderSync-required responses reached the limit.', + self::BLOCKED => 'Device is blocked.', + self::WIPE_PENDING => 'A remote wipe is pending.', + self::WIPE_COMPLETE => 'A remote wipe completed.', + self::BACKLOG_PENDING => 'Collection backlog is awaiting recovery.', + self::BACKLOG_STUCK => 'Collection backlog repeatedly triggered recovery.', + default => throw new InvalidArgumentException( + sprintf('Unknown health signal code: %s', $code) + ), + }; + } +} diff --git a/test/unit/Horde/ActiveSync/Ops/CollectionFactListTest.php b/test/unit/Horde/ActiveSync/Ops/CollectionFactListTest.php new file mode 100644 index 00000000..53819436 --- /dev/null +++ b/test/unit/Horde/ActiveSync/Ops/CollectionFactListTest.php @@ -0,0 +1,79 @@ + + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/gpl GPLv2 + * @package ActiveSync + */ + +namespace Horde\ActiveSync\Ops; + +use BadMethodCallException; +use InvalidArgumentException; +use OutOfBoundsException; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; + +#[CoversClass(CollectionFactList::class)] +#[CoversClass(CollectionFacts::class)] +class CollectionFactListTest extends TestCase +{ + public function testIndexesSnapshotsByCollectionId(): void + { + $mail = new CollectionFacts(id: 'mail', class: 'Email', serverid: 'INBOX'); + $calendar = new CollectionFacts(id: 'calendar', class: 'Calendar'); + $list = new CollectionFactList([$mail, $calendar]); + + $this->assertCount(2, $list); + $this->assertSame($mail, $list->get('mail')); + $this->assertSame($calendar, $list['calendar']); + $this->assertNull($list->get('missing')); + $this->assertFalse(isset($list['missing'])); + $this->assertSame(['mail', 'calendar'], array_map( + static fn (CollectionFacts $facts): string => $facts->id, + $list->all() + )); + } + + public function testLaterSnapshotReplacesTheSameId(): void + { + $list = new CollectionFactList([ + new CollectionFacts(id: 'mail', class: 'Email'), + new CollectionFacts(id: 'mail', class: 'Calendar'), + ]); + + $this->assertCount(1, $list); + $this->assertSame('Calendar', $list['mail']->class); + } + + public function testRejectsEntriesThatAreNotSnapshots(): void + { + $this->expectException(InvalidArgumentException::class); + new CollectionFactList([new CollectionFacts(id: 'mail'), 'mail']); + } + + public function testMissingOffsetThrows(): void + { + $list = new CollectionFactList([new CollectionFacts(id: 'mail')]); + + $this->expectException(OutOfBoundsException::class); + $list['calendar']; + } + + public function testListIsImmutable(): void + { + $list = new CollectionFactList(); + + $this->expectException(BadMethodCallException::class); + $list['mail'] = new CollectionFacts(id: 'mail'); + } +} diff --git a/test/unit/Horde/ActiveSync/Ops/DeviceHealthFactoryTest.php b/test/unit/Horde/ActiveSync/Ops/DeviceHealthFactoryTest.php new file mode 100644 index 00000000..70b6190c --- /dev/null +++ b/test/unit/Horde/ActiveSync/Ops/DeviceHealthFactoryTest.php @@ -0,0 +1,159 @@ + + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/gpl GPLv2 + * @package ActiveSync + */ + +namespace Horde\ActiveSync\Ops; + +use Horde_ActiveSync_Device; +use Horde_ActiveSync_State_Sql; +use Horde_ActiveSync_SyncCache; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; + +#[CoversClass(DeviceHealthFactory::class)] +#[CoversClass(DeviceFacts::class)] +#[CoversClass(CollectionFacts::class)] +class DeviceHealthFactoryTest extends TestCase +{ + private const NOW = 1_700_000_000; + + public function testFromRowDecodesPropertiesAndFalseValues(): void + { + $factory = $this->factory(); + $health = $factory->fromRow( + [ + 'device_id' => 'DEVICE1', + 'device_type' => 'phone', + 'device_user' => 'alice@example.com', + 'device_rwstatus' => 0, + 'device_accountonly_rwstatus' => 0, + 'device_properties' => serialize([ + 'blocked' => true, + 'version' => '16.1', + ]), + ], + [ + 'timestamp' => false, + 'hbinterval' => false, + 'wait' => 15, + 'lasthbsyncstarted' => false, + 'lastsyncendnormal' => false, + 'foldersyncrequired' => 3, + 'collections' => [ + 'collection-1' => [ + 'class' => 'Email', + 'serverid' => 'INBOX', + 'lastsynckey' => false, + 'backlog' => self::NOW - 60, + 'backlogpings' => 1, + 'pingable' => true, + ], + ], + ] + ); + + $this->assertSame('16.1', $health->version); + $this->assertSame(900, $health->hbinterval); + $this->assertNull($health->ageSeconds); + $this->assertSame( + [SignalCode::FSR_WARN, SignalCode::BLOCKED, SignalCode::BACKLOG_PENDING], + $health->signalCodes() + ); + $this->assertSame('collection-1', $health->collections[0]->id); + $this->assertNull($health->collections[0]->lastsynckey); + } + + public function testFromRowAcceptsArrayProperties(): void + { + $health = $this->factory()->fromRow( + [ + 'device_id' => 'DEVICE1', + 'device_type' => 'phone', + 'device_user' => 'alice@example.com', + 'device_rwstatus' => 0, + 'device_accountonly_rwstatus' => 0, + 'device_properties' => ['version' => '14.1'], + ], + ['collections' => []], + self::NOW + ); + + $this->assertSame('14.1', $health->version); + $this->assertTrue($health->active); + } + + public function testFromDeviceUsesLegacyObjectsAndIncludesKeylessCollections(): void + { + $state = $this->createMock(Horde_ActiveSync_State_Sql::class); + $state->method('getSyncCache')->willReturn([ + 'timestamp' => self::NOW - 10, + 'hbinterval' => 900, + 'wait' => false, + 'lasthbsyncstarted' => false, + 'lastsyncendnormal' => false, + 'foldersyncrequired' => 0, + 'collections' => [ + 'collection-1' => [ + 'class' => 'Email', + 'serverid' => 'INBOX', + 'lastsynckey' => false, + 'backlog' => self::NOW - 60, + 'backlogpings' => 3, + 'pingable' => true, + ], + ], + ]); + $device = new Horde_ActiveSync_Device($state, [ + 'id' => 'DEVICE1', + 'user' => 'alice@example.com', + 'deviceType' => 'phone', + 'rwstatus' => 0, + 'accountOnlyRwstatus' => 0, + 'properties' => [ + 'blocked' => false, + 'version' => '16.1', + ], + ]); + $cache = new Horde_ActiveSync_SyncCache( + $state, + 'DEVICE1', + 'alice@example.com' + ); + + $health = $this->factory()->fromDevice( + $device, + $cache, + self::NOW - 20 + ); + + $this->assertSame('DEVICE1', $health->deviceId); + $this->assertSame('16.1', $health->version); + $this->assertSame(10, $health->ageSeconds); + $this->assertTrue($health->active); + $this->assertSame( + [SignalCode::BACKLOG_STUCK], + $health->signalCodes() + ); + $this->assertNull($health->collections[0]->lastsynckey); + } + + private function factory(): DeviceHealthFactory + { + return new DeviceHealthFactory(new HealthEvaluator( + new HealthOptions(now: self::NOW) + )); + } +} diff --git a/test/unit/Horde/ActiveSync/Ops/HealthEvaluatorTest.php b/test/unit/Horde/ActiveSync/Ops/HealthEvaluatorTest.php new file mode 100644 index 00000000..b7a6c10a --- /dev/null +++ b/test/unit/Horde/ActiveSync/Ops/HealthEvaluatorTest.php @@ -0,0 +1,302 @@ + + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/gpl GPLv2 + * @package ActiveSync + */ + +namespace Horde\ActiveSync\Ops; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +#[CoversClass(HealthEvaluator::class)] +#[CoversClass(HealthOptions::class)] +#[CoversClass(HealthStatus::class)] +#[CoversClass(SignalCode::class)] +#[CoversClass(HealthSignal::class)] +#[CoversClass(CollectionHealth::class)] +#[CoversClass(DeviceHealth::class)] +#[CoversClass(FleetSummary::class)] +class HealthEvaluatorTest extends TestCase +{ + private const NOW = 1_700_000_000; + + /** + * @dataProvider folderSyncRequiredProvider + */ + #[DataProvider('folderSyncRequiredProvider')] + public function testFolderSyncRequiredBoundaries( + int $count, + array $expectedCodes + ): void { + $health = $this->evaluate(['foldersyncrequired' => $count]); + + $this->assertSame($expectedCodes, $health->signalCodes()); + } + + public static function folderSyncRequiredProvider(): array + { + return [ + 'zero' => [0, []], + 'below warning' => [2, []], + 'warning' => [3, [SignalCode::FSR_WARN]], + 'critical' => [5, [SignalCode::FSR_CRITICAL]], + ]; + } + + public function testHeartbeatBoundariesAndMissingEnd(): void + { + $atBoundary = $this->evaluate([ + 'lasthbsyncstarted' => self::NOW - 960, + 'lastsyncendnormal' => self::NOW - 1_000, + ]); + $this->assertSame( + [SignalCode::HB_IN_FLIGHT], + $atBoundary->signalCodes() + ); + $this->assertTrue($atBoundary->active); + $this->assertSame(HealthStatus::OK, $atBoundary->status); + + $stuck = $this->evaluate([ + 'lasthbsyncstarted' => self::NOW - 961, + 'lastsyncendnormal' => self::NOW - 1_000, + ]); + $this->assertSame([SignalCode::HB_STUCK], $stuck->signalCodes()); + $this->assertSame(HealthStatus::WARN, $stuck->status); + $this->assertTrue($stuck->isStuck()); + + $missingEnd = $this->evaluate([ + 'lasthbsyncstarted' => self::NOW - 961, + 'lastsyncendnormal' => null, + ]); + $this->assertSame( + [SignalCode::HB_MISSING_END], + $missingEnd->signalCodes() + ); + $this->assertSame(HealthStatus::WARN, $missingEnd->status); + + $stuckAtAbandonedBoundary = $this->evaluate([ + 'lasthbsyncstarted' => self::NOW - 1_920, + 'lastsyncendnormal' => self::NOW - 2_000, + ]); + $this->assertSame( + [SignalCode::HB_STUCK], + $stuckAtAbandonedBoundary->signalCodes() + ); + + $abandoned = $this->evaluate([ + 'cacheTimestamp' => self::NOW - 301, + 'lasthbsyncstarted' => self::NOW - 1_921, + 'lastsyncendnormal' => self::NOW - 2_000, + ]); + $this->assertSame( + [SignalCode::HB_ABANDONED], + $abandoned->signalCodes() + ); + $this->assertSame(HealthStatus::OK, $abandoned->status); + $this->assertFalse($abandoned->isStuck()); + $this->assertFalse($abandoned->active); + + $missingEndAtAbandonedBoundary = $this->evaluate([ + 'lasthbsyncstarted' => self::NOW - 1_920, + 'lastsyncendnormal' => null, + ]); + $this->assertSame( + [SignalCode::HB_MISSING_END], + $missingEndAtAbandonedBoundary->signalCodes() + ); + + $abandonedMissingEnd = $this->evaluate([ + 'cacheTimestamp' => self::NOW - 301, + 'lasthbsyncstarted' => self::NOW - 1_921, + 'lastsyncendnormal' => null, + ]); + $this->assertSame( + [SignalCode::HB_ABANDONED], + $abandonedMissingEnd->signalCodes() + ); + $this->assertSame(HealthStatus::OK, $abandonedMissingEnd->status); + $this->assertFalse($abandonedMissingEnd->isStuck()); + $this->assertFalse($abandonedMissingEnd->active); + } + + public function testHeartbeatAbandonedThresholdOptions(): void + { + $defaults = new HealthOptions(); + $this->assertSame(1_320, $defaults->hbAbandonedAfterFor(600)); + + $explicit = new HealthOptions(hbAbandonedAfter: 2_000); + $this->assertSame(2_000, $explicit->hbAbandonedAfterFor(600)); + + $this->assertSame(3600, $defaults->backlogAbandonedAfter); + } + + public function testBlockedAndWipeSignals(): void + { + $pending = $this->evaluate([ + 'blocked' => true, + 'rwstatus' => 2, + 'accountOnlyRwstatus' => 4, + ]); + $this->assertSame( + [SignalCode::BLOCKED, SignalCode::WIPE_PENDING], + $pending->signalCodes() + ); + + $complete = $this->evaluate([ + 'rwstatus' => 3, + 'accountOnlyRwstatus' => 5, + ]); + $this->assertSame( + [SignalCode::WIPE_COMPLETE], + $complete->signalCodes() + ); + } + + public function testBacklogBoundariesAndWorstAggregation(): void + { + $health = $this->evaluate([ + 'blocked' => true, + 'collections' => [ + new CollectionFacts( + id: 'young', + backlog: self::NOW - 59, + backlogpings: 2 + ), + new CollectionFacts( + id: 'pending', + backlog: self::NOW - 60, + backlogpings: 2 + ), + new CollectionFacts( + id: 'stuck', + backlog: self::NOW, + backlogpings: 3 + ), + new CollectionFacts( + id: 'abandoned', + backlog: self::NOW - 3600, + backlogpings: 3 + ), + ], + ]); + + $this->assertSame(HealthStatus::CRITICAL, $health->status); + $this->assertSame(HealthStatus::OK, $health->collections[0]->status); + $this->assertSame(HealthStatus::OK, $health->collections[3]->status); + $this->assertSame([], $health->collections[3]->signals); + $this->assertSame( + [SignalCode::BACKLOG_PENDING], + array_map( + static fn (HealthSignal $signal): string => $signal->code, + $health->collections[1]->signals + ) + ); + $this->assertSame( + [SignalCode::BLOCKED, SignalCode::BACKLOG_PENDING, SignalCode::BACKLOG_STUCK], + $health->signalCodes() + ); + $this->assertTrue($health->isStuck()); + } + + public function testActiveWindowEdgesAndLatestTimestamp(): void + { + $atBoundary = $this->evaluate([ + 'cacheTimestamp' => self::NOW - 300, + 'lastSyncTs' => self::NOW - 400, + ]); + $this->assertTrue($atBoundary->active); + $this->assertSame(300, $atBoundary->ageSeconds); + + $outside = $this->evaluate([ + 'cacheTimestamp' => self::NOW - 301, + ]); + $this->assertFalse($outside->active); + $this->assertSame(301, $outside->ageSeconds); + + $unknown = $this->evaluate(); + $this->assertNull($unknown->ageSeconds); + $this->assertFalse($unknown->active); + } + + public function testFleetSummaryCountsAndSerialization(): void + { + $ok = $this->evaluate(['cacheTimestamp' => self::NOW]); + $warn = $this->evaluate(['blocked' => true]); + $critical = $this->evaluate(['foldersyncrequired' => 5]); + $pending = $this->evaluate(['rwstatus' => 2]); + $summary = FleetSummary::fromDevices( + [$ok, $warn, $critical, $pending], + self::NOW + ); + + $this->assertSame(4, $summary->devices); + $this->assertSame(1, $summary->active); + $this->assertSame(1, $summary->ok); + $this->assertSame(2, $summary->warn); + $this->assertSame(1, $summary->critical); + $this->assertSame(1, $summary->stuck); + $this->assertSame(1, $summary->wipePending); + $this->assertSame(1, $summary->blocked); + $this->assertTrue($summary->hasCritical()); + $this->assertSame(self::NOW, $summary->toArray()['asOf']); + + $withPath = $warn->withLogPath('/logs/DEVICE1.txt'); + $this->assertNull($warn->logPath); + $this->assertSame('/logs/DEVICE1.txt', $withPath->toArray()['logPath']); + } + + public function testSignalCatalogAndStatusHelpers(): void + { + $this->assertCount(11, SignalCode::all()); + foreach (SignalCode::all() as $code) { + $this->assertNotSame('', SignalCode::describe($code)); + } + $this->assertTrue(HealthStatus::isValid(HealthStatus::WARN)); + $this->assertFalse(HealthStatus::isValid('unknown')); + $this->assertSame( + HealthStatus::CRITICAL, + HealthStatus::worst( + HealthStatus::OK, + HealthStatus::CRITICAL, + HealthStatus::WARN + ) + ); + } + + private function evaluate(array $values = []): DeviceHealth + { + $facts = new DeviceFacts( + user: 'alice@example.com', + deviceId: 'DEVICE1', + deviceType: 'phone', + version: '16.1', + rwstatus: $values['rwstatus'] ?? 0, + accountOnlyRwstatus: $values['accountOnlyRwstatus'] ?? 0, + blocked: $values['blocked'] ?? false, + lastSyncTs: $values['lastSyncTs'] ?? null, + cacheTimestamp: $values['cacheTimestamp'] ?? null, + hbinterval: $values['hbinterval'] ?? 900, + lasthbsyncstarted: $values['lasthbsyncstarted'] ?? null, + lastsyncendnormal: $values['lastsyncendnormal'] ?? null, + foldersyncrequired: $values['foldersyncrequired'] ?? 0, + collections: new CollectionFactList($values['collections'] ?? []) + ); + + return (new HealthEvaluator( + new HealthOptions(now: self::NOW) + ))->evaluate($facts); + } +}