From 08974736b827634e73dd4d31135dc497c2b1ed00 Mon Sep 17 00:00:00 2001 From: amirdehestani Date: Thu, 28 May 2026 17:12:04 +0200 Subject: [PATCH 1/8] Api: add REST API handler and JSON format for Tracker items --- src/ApiHandler.php | 694 +++++++++++++++++++++++++++++++++++++++++++++ src/JsTracker.php | 293 +++++++++++++++++++ 2 files changed, 987 insertions(+) create mode 100644 src/ApiHandler.php create mode 100644 src/JsTracker.php diff --git a/src/ApiHandler.php b/src/ApiHandler.php new file mode 100644 index 0000000..f337b9e --- /dev/null +++ b/src/ApiHandler.php @@ -0,0 +1,694 @@ +bo = new \tracker_bo(); + + // Tracker requires at least one queue (category) to search/list tickets. + // Auto-create a "Default" queue on first REST API use if none exist. + if (empty($this->bo->trackers)) + { + $cats = new Api\Categories(0, 'tracker'); + $cats->add([ + 'name' => 'Default', + 'owner' => 0, + 'access' => 'public', + 'data' => ['type' => 'tracker'], + ]); + // Reload after creation so search() finds the new queue. + $this->bo->trackers = $this->bo->get_tracker_labels(); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // PROPFIND — list collection + // ───────────────────────────────────────────────────────────────────────── + + /** + * Handle PROPFIND / collection GET — returns a list of all accessible tickets. + * + * @param string $path + * @param array &$options + * @param array &$files + * @param int $user account_id of the collection owner + * @param string $id ='' single-resource request + * @return bool|string true on success, HTTP status string on failure + */ + public function propfind($path, &$options, &$files, $user, $id = '') + { + $filter = []; + + // Restrict to a specific owner when user prefix is present in the URL. + // Passing null means "all accessible tickets" — the BO enforces ACL. + if ($user) + { + $filter['tr_creator'] = $user; + } + + $nresults = null; + if (($id || $options['root']['name'] !== 'propfind') && + !$this->_report_filters($options, $filter, $id, $nresults)) + { + return false; + } + + if ($id) + { + $path = dirname($path) . '/'; + } + + // sync-collection report + if ($options['root']['name'] === 'sync-collection') + { + $files['sync-token'] = [$this, 'get_sync_collection_token']; + $files['sync-token-params'] = [$path, $user]; + + $this->sync_collection_token = $this->more_results = null; + $filter['order'] = 'COALESCE(tr_modified,tr_created) ASC'; + $filter['sync-collection'] = true; + } + + $files['files'] = $this->propfind_generator($path, $filter, $files['files'] ?? [], $nresults); + + return true; + } + + /** + * Return the ctag (collection change tag) for the tracker collection. + * + * @param string $path + * @param int $user + * @return string + */ + public function getctag($path, $user) + { + // Use the most recently modified/created ticket as the ctag. + $rows = $this->bo->search('', ['tr_id'], 'COALESCE(tr_modified,tr_created) DESC', '', '', false, 'AND', [0, 1], + $user ? ['tr_creator' => $user] : [], false); + if ($rows) + { + $row = reset($rows); + return (string)$row['tr_id']; + } + return '0'; + } + + // ───────────────────────────────────────────────────────────────────────── + // propfind_generator + // ───────────────────────────────────────────────────────────────────────── + + /** + * Generator that yields resource entries for propfind in CHUNK_SIZE batches. + * + * @param string $path + * @param array &$filter + * @param array $extra extra resources (e.g. the collection root) + * @param int|null $nresults optional limit + * @param bool $report_not_found_multiget_ids + * @return \Generator + */ + public function propfind_generator( + string $path, + array &$filter, + array $extra = [], + $nresults = null, + bool $report_not_found_multiget_ids = true + ): \Generator { + $starttime = microtime(true); + + $yielded = 0; + foreach ($extra as $resource) + { + if (++$yielded && isset($nresults) && $yielded > $nresults) + { + $this->more_results = true; + return; + } + yield $resource; + } + + $order = $filter['order'] ?? 'COALESCE(egw_tracker.tr_modified,egw_tracker.tr_created) DESC'; + unset($filter['order']); + + $sync_collection_report = $filter['sync-collection'] ?? false; + unset($filter['sync-collection']); + + [$sync_token, $sync_token_offset] = $filter['sync_token_offset'] ?? [0, 0]; + unset($filter['sync_token_offset']); + $initial_offset = $sync_token_offset; + + // full-text search via criteria (first param), not col_filter + $criteria = $filter['__search__'] ?? ''; + unset($filter['__search__']); + + for ( + $chunk = 0; + ($tickets = $this->bo->search( + $criteria, false, $order, '', '', false, 'AND', + [$initial_offset + $chunk * self::CHUNK_SIZE, $nresults ?: self::CHUNK_SIZE], + $filter, false + )); + ++$chunk + ) + { + foreach ($tickets as &$ticket) + { + if ($sync_token !== ($modified = $ticket['tr_modified'] ?? $ticket['tr_created'])) + { + $sync_token = $modified; + $sync_token_offset = 0; + } + $sync_token_offset++; + + // strip prefix once — JsTracker expects no prefix + $entry = Api\Db::strip_array_keys($ticket, 'tr_'); + + if (!empty($this->requested_multiget_ids) && + ($k = array_search($entry['id'], $this->requested_multiget_ids)) !== false) + { + unset($this->requested_multiget_ids[$k]); + } + + // sync-collection: deleted items have no properties + if ((string)$ticket['tr_status'] === \tracker_so::STATUS_DELETED) + { + yield ['path' => $path . urldecode($this->get_path($entry))]; + if (++$yielded && isset($nresults) && $yielded >= $nresults) break 2; + continue; + } + + try + { + $content = JsTracker::JsTicket($ticket, false); + } + catch (\Throwable $e) + { + error_log(__METHOD__ . "() ticket tr_id={$ticket['tr_id']}: " . $e->getMessage()); + continue; + } + + $props = [ + 'getcontenttype' => Api\CalDAV::mkprop('getcontenttype', 'application/json'), + 'getlastmodified' => Api\DateTime::user2server($ticket['tr_modified'] ?? $ticket['tr_created'], 'utc'), + 'displayname' => $ticket['tr_summary'], + 'getcontentlength' => bytes(is_array($content) ? Api\CalDAV::json_encode(json_encode($content)) : $content), + 'data' => Api\CalDAV::mkprop('data', + Api\CalDAV::isJSON() || !is_array($content) ? $content : Api\CalDAV::json_encode($content) + ), + ]; + + yield $this->add_resource($path, $entry, $props); + + if (++$yielded && isset($nresults) && $yielded >= $nresults) break 2; + } + + if ($this->bo->total <= $yielded + $initial_offset) break; + } + + if ($sync_collection_report) + { + $this->sync_collection_token = $sync_token . '_' . $sync_token_offset; + if ($this->bo->total > $yielded + $initial_offset) + { + $this->more_results = true; + } + } + + if ($report_not_found_multiget_ids && !empty($this->requested_multiget_ids)) + { + foreach ($this->requested_multiget_ids as $id) + { + if (++$yielded && isset($nresults) && $yielded > $nresults) + { + $this->more_results = true; + return; + } + yield ['path' => $path . $id . self::$path_extension]; + } + } + + if ($this->debug) + { + error_log(__METHOD__ . "($path) took " . (microtime(true) - $starttime) . "s for $yielded resources"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Filter helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Map JSON attribute names / REST filter parameters to internal tracker_so column names. + * + * Supported filter keys: + * search → full-text search string + * status → ticket status label + * priority → numeric priority + * tracker → queue/tracker id + * assigned → account UID or id + * linked → ":" (linked via Api\Link) + * # → custom field value + * + * @param array $filter raw REST filter from query string + * @return array col_filter array suitable for tracker_bo::search() + */ + protected function filter2col_filter(array $filter): array + { + $cols = []; + foreach ($filter as $name => $value) + { + switch ($name) + { + case 'search': + // passed as criteria to search(), not as col_filter + $cols['__search__'] = $value; + break; + + case 'status': + try + { + $cols['tr_status'] = JsTracker::parseStatus($value); + } + catch (\Throwable $e) + { + throw new Api\Exception("Invalid status filter: " . $e->getMessage(), 400); + } + break; + + case 'priority': + $cols['tr_priority'] = (int)$value; + break; + + case 'tracker': + $cols['tr_tracker'] = (int)$value; + break; + + case 'assigned': + // assigned expects the account-id in the ASSIGNEE join + $cols['tr_assigned'] = is_numeric($value) ? (int)$value : + (int)$GLOBALS['egw']->accounts->name2id($value); + break; + + case 'linked': + if (!preg_match('/^([a-z_]+):(\d+)$/i', $value, $m) || + !isset($GLOBALS['egw_info']['user']['apps'][$m[1]]) || + (int)$m[2] <= 0) + { + throw new Api\Exception("Invalid linked-filter '$value', must be ':'", 400); + } + $ids = Api\Link::get_links($m[1], $m[2], 'tracker'); + $cols['tr_id'] = $ids ?: [0]; + break; + + default: + if ($name[0] === '#') + { + // custom field + $cols[$name] = $value; + } + else + { + $cols['tr_' . $name] = $value; + } + break; + } + } + return $cols; + } + + /** + * Process CalDAV REPORT filters (also handles JSON/REST query parameters). + * + * @param array $options + * @param array &$filters + * @param string $id + * @param int|null &$nresults + * @return bool + */ + public function _report_filters($options, &$filters, $id, &$nresults) + { + if (Api\CalDAV::isJSON() && !empty($options['filters']) && is_array($options['filters'])) + { + $mapped = $this->filter2col_filter($options['filters']); + // full-text search is passed separately from col_filter + if (isset($mapped['__search__'])) + { + $filters['__search__'] = $mapped['__search__']; + unset($mapped['__search__']); + } + $filters = $mapped + $filters; + } + + // nresults from CalDAV limit element + foreach ((array)($options['other'] ?? []) as $option) + { + if ($option['name'] === 'nresults') + { + $nresults = (int)$option['data']; + } + elseif ($option['name'] === 'sync-token' && !empty($option['data'])) + { + $parts = explode('/', $option['data']); + $filters['sync_token_offset'] = explode(self::SYNC_TOKEN_OFFSET_DELIMITER, array_pop($parts)) + [null, 0]; + $filters[] = 'COALESCE(tr_modified,tr_created)>=' . (int)$filters['sync_token_offset'][0]; + $filters['tr_status'] = 'all'; + } + } + + // single-resource request + if ($id) + { + $filters['tr_id'] = self::$path_extension ? basename($id, self::$path_extension) : $id; + } + + return true; + } + + // ───────────────────────────────────────────────────────────────────────── + // GET — single ticket + // ───────────────────────────────────────────────────────────────────────── + + /** + * Handle GET request for a single tracker item. + * + * @param array &$options + * @param int $id + * @param int $user =null account_id + * @return bool|string + */ + public function get(&$options, $id, $user = null) + { + header('Content-Type: application/json'); + + if (!is_array($ticket = $this->_common_get_put_delete('GET', $options, $id))) + { + return $ticket; + } + + try + { + if (($type = Api\CalDAV::isJSON())) + { + $options['data'] = JsTracker::JsTicket($ticket, $type); + $options['mimetype'] = 'application/json'; + + header('Content-Encoding: identity'); + header('ETag: "' . $this->get_etag($ticket) . '"'); + return true; + } + } + catch (\Throwable $e) + { + return $this->handleException($e); + } + + return '501 Not Implemented'; + } + + // ───────────────────────────────────────────────────────────────────────── + // PUT / POST / PATCH — create or update + // ───────────────────────────────────────────────────────────────────────── + + /** + * Handle PUT / POST / PATCH for a tracker item. + * + * @param array &$options + * @param int $id + * @param int $user =null collection owner + * @param string $prefix =null user prefix from path + * @param string $method ='PUT' PUT / POST / PATCH + * @param string $content_type =null + * @return bool|string + */ + public function put(&$options, $id, $user = null, $prefix = null, string $method = 'PUT', ?string $content_type = null) + { + $old = $this->_common_get_put_delete($method, $options, $id); + if (!is_null($old) && !is_array($old)) + { + return $old; + } + + try + { + $ticket = JsTracker::parseJsTicket($options['content'], $old ?: [], $content_type, $method); + } + catch (\Throwable $e) + { + return $this->handleException($e); + } + + if (is_array($old)) + { + $ticket['tr_id'] = $old['tr_id'] ?? $old['id']; + $ticket['tr_creator'] = $old['tr_creator'] ?? $old['creator']; + $ticket['tr_created'] = $old['tr_created'] ?? $old['created']; + $retval = true; + + // Pre-filter fields the user cannot modify (field_acl check). + // readonlys_from_acl() uses $this->bo->data loaded by read() above. + // Silently skipping them is correct REST behaviour — the caller can't + // know which fields the server enforces as read-only for their role. + $readonlys = $this->bo->readonlys_from_acl(); + foreach (array_keys($ticket) as $field) + { + if (!empty($readonlys[$field])) + { + unset($ticket[$field]); + } + } + } + else + { + // new ticket + if (!isset($ticket['tr_tracker'])) + { + // use the first available tracker queue the user has access to + $ticket['tr_tracker'] = key($this->bo->trackers); + } + if (!isset($ticket['tr_creator'])) + { + $ticket['tr_creator'] = $prefix && $user ? $user : $GLOBALS['egw_info']['user']['account_id']; + } + $retval = '201 Created'; + } + + // apply ETag precondition + if ($this->http_if_match) + { + $ticket['etag'] = self::etag2value($this->http_if_match); + } + + $err = $this->bo->save($ticket); + if ($err) + { + if ($this->debug) + { + error_log(__METHOD__ . "() save() failed: " . var_export($err, true)); + } + return '403 Forbidden'; + } + + // re-read after save to get auto-set fields (tr_modified, tr_status, …) + $saved = Api\Db::strip_array_keys($this->bo->data, 'tr_'); + + $this->put_response_headers($saved, $options['path'], $retval, false); + + return $retval; + } + + // ───────────────────────────────────────────────────────────────────────── + // DELETE + // ───────────────────────────────────────────────────────────────────────── + + /** + * Handle DELETE request for a tracker item. + * + * @param array &$options + * @param int $id + * @param int $user account_id of collection owner + * @return bool|string + */ + public function delete(&$options, $id, $user) + { + if (!is_array($ticket = $this->_common_get_put_delete('DELETE', $options, $id))) + { + return $ticket; + } + + $tr_id = $ticket['tr_id'] ?? $ticket['id']; + $ok = $this->bo->delete(['tr_id' => $tr_id]); + + return $ok !== false; + } + + // ───────────────────────────────────────────────────────────────────────── + // read / check_access — required by Handler base + // ───────────────────────────────────────────────────────────────────────── + + /** + * Read a single tracker entry by ID. + * + * @param int|string $id + * @return array|false|null array = found, false = no rights, null = not found + */ + public function read($id) + { + $ret = $this->bo->read(['tr_id' => $id]); + if (is_array($ret)) + { + // strip prefix so Handler base can work with 'id', 'modified', etc. + $ret = Api\Db::strip_array_keys($this->bo->data, 'tr_'); + } + return $ret; + } + + /** + * Check if the current user has the required ACL level on a tracker entry. + * + * @param int $acl Api\Acl::READ / EDIT / DELETE (1 / 2 / 8) + * @param array|int $entry tracker entry array (with tr_* or stripped keys) or tr_id + * @return bool|null true = access, false = no access, null = not found + */ + public function check_access($acl, $entry) + { + // null entry means "new record" — return null (not false) so _common_get_put_delete + // doesn't block creation. Matches the timesheet pattern: null means "not applicable". + if (is_null($entry)) + { + return null; + } + + // Normalise to tr_* prefixed keys so check_rights finds what it needs. + if (is_array($entry) && isset($entry['id']) && !isset($entry['tr_id'])) + { + $data = []; + foreach ($entry as $k => $v) + { + $data['tr_' . $k] = $v; + } + // cat_id has no tr_ prefix in the tracker schema + if (isset($entry['cat_id'])) $data['cat_id'] = $entry['cat_id']; + } + elseif (is_array($entry)) + { + $data = $entry; + } + else + { + // scalar ID — pass directly; tracker_bo::check_rights will read the entry + $data = $entry; + } + + switch ($acl) + { + case Api\Acl::READ: + $needed = TRACKER_ADMIN | TRACKER_TECHNICIAN | TRACKER_USER | + TRACKER_ITEM_CREATOR | TRACKER_ITEM_ASSIGNEE | TRACKER_EVERYBODY; + break; + + case Api\Acl::EDIT: + $needed = TRACKER_ADMIN | TRACKER_TECHNICIAN | TRACKER_ITEM_CREATOR | TRACKER_ITEM_ASSIGNEE; + break; + + case Api\Acl::DELETE: + $needed = TRACKER_ADMIN | TRACKER_ITEM_CREATOR; + break; + + default: + $needed = TRACKER_ADMIN; + break; + } + + return $this->bo->check_rights($needed, null, $data); + } + + // ───────────────────────────────────────────────────────────────────────── + // Exception helper (mirrors timesheet ApiHandler) + // ───────────────────────────────────────────────────────────────────────── + + /** + * Turn an exception into an appropriate HTTP error response. + * + * @param \Throwable $e + * @return string HTTP status string + */ + protected function handleException(\Throwable $e): string + { + _egw_log_exception($e); + header('Content-Type: application/json'); + echo json_encode( + [ + 'error' => $code = $e->getCode() ?: 500, + 'message' => $e->getMessage(), + 'details' => $e->details ?? null, + 'script' => $e->script ?? null, + ] + (empty($GLOBALS['egw_info']['server']['exception_show_trace']) ? [] : [ + 'trace' => array_map(static function ($trace) { + $trace['file'] = str_replace(EGW_SERVER_ROOT . '/', '', $trace['file'] ?? ''); + return $trace; + }, $e->getTrace()), + ]), + self::JSON_RESPONSE_OPTIONS + ); + return (400 <= $code && $code < 600 ? $code : 500) . ' ' . $e->getMessage(); + } +} diff --git a/src/JsTracker.php b/src/JsTracker.php new file mode 100644 index 0000000..0f873f2 --- /dev/null +++ b/src/JsTracker.php @@ -0,0 +1,293 @@ + 'Open', + \tracker_so::STATUS_CLOSED => 'Closed', + \tracker_so::STATUS_DELETED => 'Deleted', + \tracker_so::STATUS_PENDING => 'Pending', + ]; + + /** + * Build the JSON representation of a tracker item. + * + * @param int|array $ticket tracker item (tr_* prefixed array) or tr_id + * @param bool|"pretty" $encode true = JSON string, false = raw array + * @return string|array + * @throws Api\Exception\NotFound + */ + public static function JsTicket($ticket, $encode = true) + { + static $bo = null; + if (!isset($bo)) $bo = new \tracker_bo(); + + if (is_scalar($ticket) && !($ticket = $bo->read(['tr_id' => $ticket]))) + { + throw new Api\Exception\NotFound(); + } + + // strip tr_ prefix so we work with clean keys + if (isset($ticket['tr_id'])) + { + $ticket = Api\Db::strip_array_keys($ticket, 'tr_'); + } + + // resolve any custom tracker stati stored in $bo->estat + $status_label = self::STATUS_LABELS[$ticket['status']] ?? + ($bo->estat[$ticket['tracker']][$ticket['status']] ?? (string)$ticket['status']); + + $data = array_filter([ + self::AT_TYPE => self::TYPE_TICKET, + 'id' => (int)$ticket['id'], + 'summary' => $ticket['summary'], + 'description' => $ticket['description'] ?: null, + 'tracker' => (int)$ticket['tracker'] ?: null, + 'status' => $status_label, + 'priority' => (int)$ticket['priority'], + 'completion' => (int)$ticket['completion'], + 'startDate' => !empty($ticket['startdate']) ? self::UTCDateTime($ticket['startdate'], true) : null, + 'dueDate' => !empty($ticket['duedate']) ? self::UTCDateTime($ticket['duedate'], true) : null, + 'closed' => !empty($ticket['closed']) ? self::UTCDateTime($ticket['closed'], true) : null, + 'private' => (bool)$ticket['private'], + 'category' => self::categories($ticket['cat_id']), + 'version' => $ticket['version'] ? self::categories($ticket['version']) : null, + 'creator' => self::account($ticket['creator']), + 'created' => self::UTCDateTime($ticket['created'], true), + 'modified' => !empty($ticket['modified']) ? self::UTCDateTime($ticket['modified'], true) : null, + 'modifier' => !empty($ticket['modifier']) ? self::account($ticket['modifier']) : null, + 'assigned' => !empty($ticket['assigned']) ? self::assigned($ticket['assigned']) : null, + 'cc' => $ticket['cc'] ?: null, + 'group' => !empty($ticket['group']) ? self::account($ticket['group']) : null, + 'egroupware.org:customfields' => self::customfields($ticket), + 'etag' => ApiHandler::etag($ticket), + ]); + + // @type and private must always be present even when falsy + $data[self::AT_TYPE] = self::TYPE_TICKET; + $data['private'] = (bool)$ticket['private']; + + if ($encode) + { + return Api\CalDAV::json_encode($data, $encode === 'pretty'); + } + return $data; + } + + /** + * Format the assigned list (array of account-ids) as JSON. + * + * @param array|int $assigned + * @return array|null + */ + protected static function assigned($assigned) + { + if (!$assigned) return null; + $result = []; + foreach ((array)$assigned as $uid) + { + if ($uid) $result[] = self::account($uid); + } + return $result ?: null; + } + + /** + * Parse a JSON tracker ticket (PUT / POST / PATCH body). + * + * @param string $json raw request body + * @param array $old existing record for PATCH merging + * @param ?string $content_type + * @param string $method PUT / POST / PATCH + * @return array with tr_* keys ready for tracker_bo::save() + * @throws Api\CalDAV\JsParseException + */ + public static function parseJsTicket(string $json, array $old = [], ?string $content_type = null, string $method = 'PUT'): array + { + try + { + $data = json_decode($json, true, 10, JSON_THROW_ON_ERROR); + + // For PATCH: only parse what's in the request body. + // Do NOT re-serialize $old and merge — that converts raw IDs to display names + // and causes lookup failures. so_sql::save() will merge the partial update + // with the existing $this->bo->data that was already loaded by read(). + if ($method !== 'PATCH' && empty($data['summary'])) + { + throw new Api\CalDAV\JsParseException("Required field 'summary' missing"); + } + + $ticket = []; + + foreach ($data as $name => $value) + { + switch ($name) + { + case 'summary': + $ticket['tr_summary'] = $value; + break; + + case 'description': + $ticket['tr_description'] = $value; + break; + + case 'tracker': + $ticket['tr_tracker'] = self::parseInt($value); + break; + + case 'status': + $ticket['tr_status'] = self::parseStatus($value); + break; + + case 'priority': + $ticket['tr_priority'] = self::parseInt($value); + break; + + case 'completion': + $ticket['tr_completion'] = min(100, max(0, self::parseInt($value))); + break; + + case 'startDate': + $ticket['tr_startdate'] = $value ? self::parseDateTime($value) : null; + break; + + case 'dueDate': + $ticket['tr_duedate'] = $value ? self::parseDateTime($value) : null; + break; + + case 'private': + $ticket['tr_private'] = $value ? 1 : 0; + break; + + case 'category': + $ticket['cat_id'] = self::parseCategories($value, false); + break; + + case 'version': + $ticket['tr_version'] = $value ? self::parseInt($value) : null; + break; + + case 'creator': + $ticket['tr_creator'] = self::parseAccount($value); + break; + + case 'assigned': + $ticket['tr_assigned'] = self::parseAssigned($value); + break; + + case 'cc': + $ticket['tr_cc'] = $value; + break; + + case 'group': + $ticket['tr_group'] = $value ? self::parseAccount($value) : null; + break; + + case 'egroupware.org:customfields': + $ticket = array_merge($ticket, self::parseCustomfields($value)); + break; + + // read-only / auto-set fields — silently ignore + case self::AT_TYPE: + case 'id': + case 'etag': + case 'created': + case 'modified': + case 'modifier': + case 'closed': + break; + + default: + error_log(__METHOD__ . "() unknown field $name=" . json_encode($value, self::JSON_OPTIONS_ERROR) . ' --> ignored'); + break; + } + } + } + catch (\Throwable $e) + { + self::handleExceptions($e, 'JsTracker', $name ?? '', $value ?? null); + } + + return $ticket; + } + + /** + * Parse a status label back to its integer value. + * + * Accepts both the built-in labels (Open / Closed / Deleted / Pending) and + * any custom queue stati stored in tracker config. + * + * @param string $value + * @return int + * @throws Api\CalDAV\JsParseException + */ + public static function parseStatus(string $value): int + { + // built-in stati + if (($id = array_search($value, self::STATUS_LABELS, true)) !== false) + { + return (int)$id; + } + + // custom tracker-specific stati + static $bo = null; + if (!isset($bo)) $bo = new \tracker_bo(); + + foreach ((array)$bo->estat as $tracker_stati) + { + if (($id = array_search($value, (array)$tracker_stati, true)) !== false) + { + return (int)$id; + } + } + + throw new Api\CalDAV\JsParseException("Invalid status '$value'"); + } + + /** + * Parse the assigned field: accepts a single account-object or an array of them. + * + * @param mixed $value + * @return array flat array of account_ids + */ + protected static function parseAssigned($value): array + { + if (!$value) return []; + if (isset($value['uid'])) $value = [$value]; // single object + $ids = []; + foreach ($value as $item) + { + if (($uid = self::parseAccount($item))) + { + $ids[] = $uid; + } + } + return $ids; + } +} From 6c54da568523e2b0a303030b1c2fc01cbb1408d8 Mon Sep 17 00:00:00 2001 From: amirdehestani Date: Tue, 2 Jun 2026 21:17:17 +0200 Subject: [PATCH 2/8] Add tracker reply REST endpoints --- src/ApiHandler.php | 265 +++++++++++++++++++++++++++++++++++++++++++++ src/JsTracker.php | 95 ++++++++++++++++ 2 files changed, 360 insertions(+) diff --git a/src/ApiHandler.php b/src/ApiHandler.php index f337b9e..c43c586 100644 --- a/src/ApiHandler.php +++ b/src/ApiHandler.php @@ -439,11 +439,26 @@ public function get(&$options, $id, $user = null) { header('Content-Type: application/json'); + // ── Reply sub-resource ───────────────────────────────────────────────── + if (preg_match('#/tracker/\d+/replies(?:/(\d+))?/?$#', $options['path'], $m)) + { + return $this->getReplies($options, (int)$id, isset($m[1]) ? (int)$m[1] : null); + } + // ────────────────────────────────────────────────────────────────────── + if (!is_array($ticket = $this->_common_get_put_delete('GET', $options, $id))) { return $ticket; } + // Load replies so JsTicket() includes the reply map + $this->bo->read_extra( + $this->bo->is_admin($this->bo->data['tr_tracker']), + $this->bo->is_technician($this->bo->data['tr_tracker']), + null, true + ); + $ticket = Api\Db::strip_array_keys($this->bo->data, 'tr_'); + try { if (($type = Api\CalDAV::isJSON())) @@ -481,6 +496,15 @@ public function get(&$options, $id, $user = null) */ public function put(&$options, $id, $user = null, $prefix = null, string $method = 'PUT', ?string $content_type = null) { + // ── Reply sub-resource ───────────────────────────────────────────────── + if (preg_match('#/tracker/\d+/replies(?:/(\d+))?/?$#', $options['path'], $m)) + { + if ($method === 'POST') return $this->createReply($options, (int)$id); + if (isset($m[1])) return $this->updateReply($options, (int)$id, (int)$m[1], $method); + return '405 Method Not Allowed'; + } + // ────────────────────────────────────────────────────────────────────── + $old = $this->_common_get_put_delete($method, $options, $id); if (!is_null($old) && !is_array($old)) { @@ -569,6 +593,13 @@ public function put(&$options, $id, $user = null, $prefix = null, string $method */ public function delete(&$options, $id, $user) { + // ── Reply sub-resource ───────────────────────────────────────────────── + if (preg_match('#/tracker/\d+/replies/(\d+)/?$#', $options['path'], $m)) + { + return $this->deleteReply($options, (int)$id, (int)$m[1]); + } + // ────────────────────────────────────────────────────────────────────── + if (!is_array($ticket = $this->_common_get_put_delete('DELETE', $options, $id))) { return $ticket; @@ -661,6 +692,240 @@ public function check_access($acl, $entry) return $this->bo->check_rights($needed, null, $data); } + // ───────────────────────────────────────────────────────────────────────── + // Reply sub-resource handlers + // ───────────────────────────────────────────────────────────────────────── + + /** + * GET /tracker/{id}/replies[/{reply_id}] + * + * Without reply_id: returns all visible replies as `{ "": {…}, … }`. + * With reply_id: returns the single Reply object. + * + * @param array &$options + * @param int $ticket_id + * @param int|null $reply_id null = list all + * @return bool|string true (body in $options['data']) or HTTP status string + */ + protected function getReplies(array &$options, int $ticket_id, ?int $reply_id) + { + $tid = $ticket_id; + $ticket_check = $this->_common_get_put_delete('GET', $options, $tid); + if (!is_array($ticket_check)) + { + return is_string($ticket_check) ? $ticket_check : '404 Not found'; + } + + $this->bo->read_extra( + $this->bo->is_admin($this->bo->data['tr_tracker']), + $this->bo->is_technician($this->bo->data['tr_tracker']), + null, true + ); + $replies = $this->bo->data['replies'] ?? []; + + if ($reply_id !== null) + { + foreach ($replies as $reply) + { + if ((int)$reply['reply_id'] === $reply_id) + { + $options['data'] = JsTracker::JsReply($reply); + $options['mimetype'] = 'application/json'; + header('Content-Encoding: identity'); + return true; + } + } + return '404 Not found'; + } + + $map = []; + foreach ($replies as $reply) + { + $map[(string)$reply['reply_id']] = JsTracker::JsReply($reply, false); + } + $options['data'] = Api\CalDAV::json_encode($map); + $options['mimetype'] = 'application/json'; + header('Content-Encoding: identity'); + return true; + } + + /** + * POST /tracker/{id}/replies/ + * + * Creates a new reply on the given ticket. + * Returns 201 Created with a Location header pointing to the new reply. + * + * @param array &$options + * @param int $ticket_id + * @return string HTTP status string + */ + protected function createReply(array &$options, int $ticket_id): string + { + $tid = $ticket_id; + $ticket_check = $this->_common_get_put_delete('GET', $options, $tid); + if (!is_array($ticket_check)) + { + return is_string($ticket_check) ? $ticket_check : '403 Forbidden'; + } + + try + { + $parsed = JsTracker::parseJsReply($options['content'], [], 'POST'); + } + catch (\Throwable $e) + { + return $this->handleException($e); + } + + $this->bo->data['reply_message'] = $parsed['reply_message']; + $this->bo->data['reply_visible'] = $parsed['reply_visible'] ?? 0; + // reply_creator and reply_created are set automatically by tracker_bo::save() + + $err = $this->bo->save(); + if ($err) + { + return '403 Forbidden'; + } + + // tracker_bo::save() prepends the new reply via array_unshift + $reply_id = (int)$this->bo->data['replies'][0]['reply_id']; + $base = preg_replace('#/replies.*$#', '', rtrim($options['path'], '/')); + header('Location: ' . $this->base_uri . $base . '/replies/' . $reply_id); + + return '201 Created'; + } + + /** + * PUT / PATCH /tracker/{id}/replies/{reply_id} + * + * Replaces or partially updates a reply. Only `message` and `restricted` + * can be changed; all other fields are read-only. Admin/technicians may + * edit any reply; regular users may only edit their own replies. + * + * @param array &$options + * @param int $ticket_id + * @param int $reply_id + * @param string $method PUT or PATCH + * @return string HTTP status string + */ + protected function updateReply(array &$options, int $ticket_id, int $reply_id, string $method): string + { + $tid = $ticket_id; + $ticket_check = $this->_common_get_put_delete('GET', $options, $tid); + if (!is_array($ticket_check)) + { + return is_string($ticket_check) ? $ticket_check : '404 Not found'; + } + + $this->bo->read_extra( + $this->bo->is_admin($this->bo->data['tr_tracker']), + $this->bo->is_technician($this->bo->data['tr_tracker']), + null, true + ); + + $reply = null; + foreach ($this->bo->data['replies'] ?? [] as $r) + { + if ((int)$r['reply_id'] === $reply_id) + { + $reply = $r; + break; + } + } + if ($reply === null) return '404 Not found'; + + $uid = (int)$GLOBALS['egw_info']['user']['account_id']; + if ((int)$reply['reply_creator'] !== $uid && + !$this->bo->check_rights(TRACKER_ADMIN | TRACKER_TECHNICIAN, null, $this->bo->data)) + { + return '403 Forbidden'; + } + + try + { + $parsed = JsTracker::parseJsReply($options['content'], $reply, $method); + } + catch (\Throwable $e) + { + return $this->handleException($e); + } + + $update = ['reply_id' => $reply_id, 'tr_id' => $ticket_id]; + $update['reply_message'] = $parsed['reply_message'] ?? $reply['reply_message']; + if (array_key_exists('reply_visible', $parsed)) + { + $update['reply_visible'] = $parsed['reply_visible']; + } + elseif ($method !== 'PATCH') + { + $update['reply_visible'] = (int)$reply['reply_visible']; + } + + try + { + $this->bo->save_comment($update); + } + catch (\Throwable $e) + { + return $this->handleException($e); + } + + return '204 No Content'; + } + + /** + * DELETE /tracker/{id}/replies/{reply_id} + * + * Deletes a single reply. Admin/technicians may delete any reply; regular + * users may only delete their own replies. + * + * @param array &$options + * @param int $ticket_id + * @param int $reply_id + * @return string HTTP status string + */ + protected function deleteReply(array &$options, int $ticket_id, int $reply_id): string + { + $tid = $ticket_id; + $ticket_check = $this->_common_get_put_delete('GET', $options, $tid); + if (!is_array($ticket_check)) + { + return is_string($ticket_check) ? $ticket_check : '404 Not found'; + } + + $this->bo->read_extra( + $this->bo->is_admin($this->bo->data['tr_tracker']), + $this->bo->is_technician($this->bo->data['tr_tracker']), + null, true + ); + + $reply = null; + foreach ($this->bo->data['replies'] ?? [] as $r) + { + if ((int)$r['reply_id'] === $reply_id) + { + $reply = $r; + break; + } + } + if ($reply === null) return '404 Not found'; + + $uid = (int)$GLOBALS['egw_info']['user']['account_id']; + if ((int)$reply['reply_creator'] !== $uid && + !$this->bo->check_rights(TRACKER_ADMIN | TRACKER_TECHNICIAN, null, $this->bo->data)) + { + return '403 Forbidden'; + } + + $GLOBALS['egw']->db->delete( + \tracker_so::REPLIES_TABLE, + ['reply_id' => $reply_id, 'tr_id' => $ticket_id], + __LINE__, __FILE__, 'tracker' + ); + + return '204 No Content'; + } + // ───────────────────────────────────────────────────────────────────────── // Exception helper (mirrors timesheet ApiHandler) // ───────────────────────────────────────────────────────────────────────── diff --git a/src/JsTracker.php b/src/JsTracker.php index 0f873f2..168f344 100644 --- a/src/JsTracker.php +++ b/src/JsTracker.php @@ -26,6 +26,8 @@ class JsTracker extends Api\CalDAV\JsBase const TYPE_TICKET = 'Ticket'; + const TYPE_REPLY = 'Reply'; + /** * Status integer → label map (mirrors tracker_so constants) */ @@ -94,6 +96,17 @@ public static function JsTicket($ticket, $encode = true) $data[self::AT_TYPE] = self::TYPE_TICKET; $data['private'] = (bool)$ticket['private']; + // Include replies when loaded via read_extra($read_replies=true) + if (!empty($ticket['replies'])) + { + $replies_map = []; + foreach ($ticket['replies'] as $reply) + { + $replies_map[(string)$reply['reply_id']] = self::JsReply($reply, false); + } + $data['replies'] = $replies_map; + } + if ($encode) { return Api\CalDAV::json_encode($data, $encode === 'pretty'); @@ -237,6 +250,88 @@ public static function parseJsTicket(string $json, array $old = [], ?string $con return $ticket; } + /** + * Build the JSON representation of a single reply. + * + * @param array $reply row from egw_tracker_replies (reply_* keys, reply_created in user-TZ) + * @param bool|"pretty" $encode true = JSON string, false = raw array + * @return string|array + */ + public static function JsReply(array $reply, $encode = true) + { + $data = [ + self::AT_TYPE => self::TYPE_REPLY, + 'id' => (int)$reply['reply_id'], + 'message' => (string)$reply['reply_message'], + 'creator' => self::account((int)$reply['reply_creator']), + 'created' => !empty($reply['reply_created']) + ? self::UTCDateTime($reply['reply_created'], true) + : null, + 'restricted' => (bool)$reply['reply_visible'], + ]; + + if ($encode) + { + return Api\CalDAV::json_encode($data, $encode === 'pretty'); + } + return $data; + } + + /** + * Parse a reply JSON body (POST / PUT / PATCH request body). + * + * @param string $json raw request body + * @param array $old existing reply row for PATCH merging (reply_* keys) + * @param string $method POST / PUT / PATCH + * @return array reply_* prefixed fields ready for save_comment() + * @throws Api\CalDAV\JsParseException + */ + public static function parseJsReply(string $json, array $old = [], string $method = 'POST'): array + { + $name = $value = null; + try + { + $data = json_decode($json, true, 5, JSON_THROW_ON_ERROR); + + if ($method !== 'PATCH' && empty($data['message'])) + { + throw new Api\CalDAV\JsParseException("Required field 'message' missing"); + } + + $reply = []; + foreach ($data as $name => $value) + { + switch ($name) + { + case 'message': + $reply['reply_message'] = (string)$value; + break; + + case 'restricted': + $reply['reply_visible'] = $value ? 1 : 0; + break; + + // read-only fields — silently ignore + case self::AT_TYPE: + case 'id': + case 'creator': + case 'created': + break; + + default: + error_log(__METHOD__ . "() unknown field $name=" . json_encode($value, self::JSON_OPTIONS_ERROR) . ' --> ignored'); + break; + } + } + } + catch (\Throwable $e) + { + self::handleExceptions($e, 'JsReply', $name ?? '', $value ?? null); + } + + return $reply; + } + /** * Parse a status label back to its integer value. * From 2562ba79619bb5dac467a2c28828be9a8380ad9d Mon Sep 17 00:00:00 2001 From: Amir Dehestani Date: Tue, 2 Jun 2026 22:06:45 +0200 Subject: [PATCH 3/8] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/ApiHandler.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ApiHandler.php b/src/ApiHandler.php index c43c586..04c919c 100644 --- a/src/ApiHandler.php +++ b/src/ApiHandler.php @@ -246,14 +246,14 @@ public function propfind_generator( continue; } + $response_content = Api\CalDAV::isJSON() || !is_array($content) ? $content : Api\CalDAV::json_encode($content); + $props = [ 'getcontenttype' => Api\CalDAV::mkprop('getcontenttype', 'application/json'), 'getlastmodified' => Api\DateTime::user2server($ticket['tr_modified'] ?? $ticket['tr_created'], 'utc'), 'displayname' => $ticket['tr_summary'], - 'getcontentlength' => bytes(is_array($content) ? Api\CalDAV::json_encode(json_encode($content)) : $content), - 'data' => Api\CalDAV::mkprop('data', - Api\CalDAV::isJSON() || !is_array($content) ? $content : Api\CalDAV::json_encode($content) - ), + 'getcontentlength' => bytes($response_content), + 'data' => Api\CalDAV::mkprop('data', $response_content), ]; yield $this->add_resource($path, $entry, $props); From 6beaee79c987fe43174a8d8d91262c0f79a14945 Mon Sep 17 00:00:00 2001 From: Amir Dehestani Date: Tue, 2 Jun 2026 22:07:13 +0200 Subject: [PATCH 4/8] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/ApiHandler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ApiHandler.php b/src/ApiHandler.php index 04c919c..b98246d 100644 --- a/src/ApiHandler.php +++ b/src/ApiHandler.php @@ -266,7 +266,7 @@ public function propfind_generator( if ($sync_collection_report) { - $this->sync_collection_token = $sync_token . '_' . $sync_token_offset; + $this->sync_collection_token = $sync_token . self::SYNC_TOKEN_OFFSET_DELIMITER . $sync_token_offset; if ($this->bo->total > $yielded + $initial_offset) { $this->more_results = true; From 16a2757ddc17a4329a5314874e0d852b7218f3f4 Mon Sep 17 00:00:00 2001 From: amirdehestani Date: Tue, 2 Jun 2026 22:13:00 +0200 Subject: [PATCH 5/8] Fix tracker propfind paging for REST API Align propfind paging stride with the fetch size so nresults values above CHUNK_SIZE do not overlap pages, and only emit deleted tickets as path-only entries during sync-collection reports. Based on Copilot review. --- src/ApiHandler.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ApiHandler.php b/src/ApiHandler.php index b98246d..8d292dc 100644 --- a/src/ApiHandler.php +++ b/src/ApiHandler.php @@ -200,11 +200,13 @@ public function propfind_generator( $criteria = $filter['__search__'] ?? ''; unset($filter['__search__']); + $page_size = isset($nresults) ? min($nresults, self::CHUNK_SIZE) : self::CHUNK_SIZE; + for ( $chunk = 0; ($tickets = $this->bo->search( $criteria, false, $order, '', '', false, 'AND', - [$initial_offset + $chunk * self::CHUNK_SIZE, $nresults ?: self::CHUNK_SIZE], + [$initial_offset + $chunk * $page_size, $page_size], $filter, false )); ++$chunk @@ -229,7 +231,7 @@ public function propfind_generator( } // sync-collection: deleted items have no properties - if ((string)$ticket['tr_status'] === \tracker_so::STATUS_DELETED) + if ($sync_collection_report && (string)$ticket['tr_status'] === \tracker_so::STATUS_DELETED) { yield ['path' => $path . urldecode($this->get_path($entry))]; if (++$yielded && isset($nresults) && $yielded >= $nresults) break 2; From 23af1dccbbba2312eb47c89535c5960e5080fdde Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:52:51 +0000 Subject: [PATCH 6/8] fix: validate JSON payload type in parseJsTicket --- src/JsTracker.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/JsTracker.php b/src/JsTracker.php index 168f344..4e6365d 100644 --- a/src/JsTracker.php +++ b/src/JsTracker.php @@ -147,6 +147,20 @@ public static function parseJsTicket(string $json, array $old = [], ?string $con { $data = json_decode($json, true, 10, JSON_THROW_ON_ERROR); + if (!is_array($data)) + { + if ($method !== 'PATCH') + { + throw new Api\CalDAV\JsParseException('Invalid JSON body, expected an object'); + } + $data = []; + } + + if ($method !== 'PATCH' && $data === []) + { + throw new Api\CalDAV\JsParseException('Empty request body'); + } + // For PATCH: only parse what's in the request body. // Do NOT re-serialize $old and merge — that converts raw IDs to display names // and causes lookup failures. so_sql::save() will merge the partial update From 68f3af4b06466e89ea29a7c39fb9db4cdb23f7f4 Mon Sep 17 00:00:00 2001 From: Amir Dehestani Date: Mon, 8 Jun 2026 22:33:04 +0200 Subject: [PATCH 7/8] Add REST API tests for Tracker CRUD and ACL/permissions Adds two new PHPUnit test classes under tests/REST/: - TrackerRestCreateReadDelete: covers create, read, update and delete of tracker tickets via the JSON REST API (groupdav.php endpoint). - TrackerRestPermissions: covers role-based access scenarios for manager, technician and reporter actors against the same endpoint. Both suites auto-skip until a tracker_groupdav handler is implemented. --- tests/REST/TrackerRestCreateReadDelete.php | 366 ++++++++++++++++++ tests/REST/TrackerRestPermissions.php | 418 +++++++++++++++++++++ 2 files changed, 784 insertions(+) create mode 100644 tests/REST/TrackerRestCreateReadDelete.php create mode 100644 tests/REST/TrackerRestPermissions.php diff --git a/tests/REST/TrackerRestCreateReadDelete.php b/tests/REST/TrackerRestCreateReadDelete.php new file mode 100644 index 0000000..a67c46c --- /dev/null +++ b/tests/REST/TrackerRestCreateReadDelete.php @@ -0,0 +1,366 @@ + "Ticket" (identifies the resource type) + * - "uid" => string (stable cross-system identifier) + * - "title" => string (maps to tr_summary) + * - "description" => string (maps to tr_description) + * - "status" => "open" | "closed" | "pending" (maps to tr_status) + * - "priority" => 1..9 (maps to tr_priority; 5 = medium) + * + * @link http://www.egroupware.org + * @author Amir Dehestani + * @package tracker + * @subpackage tests + * @copyright (c) 2026 by EGroupware GmbH + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + */ + +namespace EGroupware\Tracker; + +require_once __DIR__.'/../../../api/tests/RestTest.php'; + +use EGroupware\Api\RestTest; +use GuzzleHttp\RequestOptions; + +/** + * Basic CRUD lifecycle for Tracker tickets via the JSON REST API. + * + * @covers tracker_groupdav::get() + * @covers tracker_groupdav::put() + * @covers tracker_groupdav::delete() + */ +class TrackerRestCreateReadDelete extends RestTest +{ + /** + * MIME type used for tracker ticket resources. + * "application/json" is the generic fallback; once a tracker_groupdav + * handler is implemented it may define a specific subtype such as + * "application/egw-tracker+json". + */ + const MIME_TYPE_TICKET = 'application/json'; + + /** + * UID of the test ticket — full path is built dynamically using EGW_USER. + */ + const TICKET_UID = 'rest-api-test-ticket-11223344'; + + /** + * Build the path for the test ticket using the configured EGW_USER. + */ + protected function ticketUrl(): string + { + return $this->appUrl('tracker', self::TICKET_UID); + } + + /** + * Minimal JSON body for a new tracker ticket. + * + * The uid must match the last path segment of TICKET_UID so the server can + * map a PUT to a stable record (same convention as calendar events). + */ + const TICKET_JSON = << false, + RequestOptions::VERIFY => false, + RequestOptions::ALLOW_REDIRECTS => true, + RequestOptions::AUTH => [ + $GLOBALS['EGW_USER'] ?? 'demo', + $GLOBALS['EGW_PASSWORD'] ?? 'guest', + ], + ]); + $base = $_ENV['EGW_URL'] ?? getenv('EGW_URL') ?: self::CALDAV_BASE; + $base = rtrim($base, '/') . (strpos($base, 'groupdav.php') === false ? '/groupdav.php' : ''); + $user = $GLOBALS['EGW_USER'] ?? 'demo'; + + // Probe with a PUT to detect whether the tracker REST handler is implemented. + // A generic CalDAV collection returns 200 on GET but 403/405/501 on PUT. + // Only 201 or 204 confirms that the handler can actually create tickets. + $probeUid = 'tracker-probe-skip-check-00000000'; + $probe = $client->put("$base/$user/tracker/$probeUid", [ + RequestOptions::HEADERS => [ + 'Content-Type' => 'application/json', + 'If-None-Match' => '*', + ], + RequestOptions::BODY => json_encode([ + '@type' => 'Ticket', + 'uid' => $probeUid, + 'title' => 'Probe ticket (auto-deleted)', + 'status' => 'open', + ]), + ]); + + if (!in_array($probe->getStatusCode(), [201, 204], true)) + { + self::markTestSkipped( + 'Tracker REST API (tracker_groupdav) is not yet implemented ' + .'(PUT probe returned HTTP '.$probe->getStatusCode().'). ' + .'Implement tracker_groupdav following the calendar_groupdav pattern, ' + .'then re-run these tests.' + ); + } + + // Clean up probe ticket + $location = $probe->getHeaderLine('Location') ?: "$base/$user/tracker/$probeUid"; + $client->delete($location, [RequestOptions::HEADERS => ['Accept' => 'application/json']]); + } + + // ------------------------------------------------------------------------- + // Authentication + // ------------------------------------------------------------------------- + + /** + * Unauthenticated requests to the tracker collection must return 401. + */ + public function testNoAuth() + { + $response = $this->getClient([])->get($this->appUrl('tracker'), [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + + $this->assertHttpStatus(401, $response); + } + + /** + * Authenticated GET on the tracker collection must return 200 with JSON. + */ + public function testAuth() + { + $response = $this->getClient()->get($this->appUrl('tracker'), [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + + $this->assertHttpStatus(200, $response); + $this->assertStringContainsString('json', $response->getHeaderLine('Content-Type'), + 'Tracker collection response must be JSON'); + } + + // ------------------------------------------------------------------------- + // CRUD lifecycle + // ------------------------------------------------------------------------- + + /** + * PUT a new ticket as JSON. The server must respond with 201 Created. + */ + public function testCreate() + { + $response = $this->getClient()->put($this->ticketUrl(), [ + RequestOptions::HEADERS => [ + 'Content-Type' => self::MIME_TYPE_TICKET, + 'If-None-Match' => '*', + ], + RequestOptions::BODY => self::TICKET_JSON, + ]); + + $this->assertHttpStatus(201, $response, 'Creating a new tracker ticket'); + } + + /** + * GET the just-created ticket; the response must include the fields we sent. + */ + public function testRead() + { + $response = $this->getClient()->get($this->ticketUrl(), [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + + $this->assertHttpStatus(200, $response, 'Reading the created ticket'); + $this->assertJsonFields([ + '@type' => 'Ticket', + 'uid' => 'rest-api-test-ticket-11223344', + 'title' => 'REST API Test Ticket', + 'status' => 'open', + 'priority' => 5, + ], $response, 'Ticket fields after create'); + } + + /** + * PATCH the ticket to change its status to "pending". + * The server must respond 200 (with updated body) or 204. + */ + public function testUpdateStatus() + { + $patch = json_encode(['status' => 'pending']); + + $response = $this->getClient()->patch($this->ticketUrl(), [ + RequestOptions::HEADERS => ['Content-Type' => self::MIME_TYPE_TICKET], + RequestOptions::BODY => $patch, + ]); + + $this->assertHttpStatus([200, 204], $response, 'Patching ticket status to pending'); + + // Read back to verify + $get = $this->getClient()->get($this->ticketUrl(), [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + $this->assertHttpStatus(200, $get); + $this->assertJsonFields(['status' => 'pending'], $get, 'Status must be persisted'); + } + + /** + * Full PUT to update multiple fields at once (title + priority + description). + */ + public function testUpdateFull() + { + $updated = json_encode([ + '@type' => 'Ticket', + 'uid' => 'rest-api-test-ticket-11223344', + 'title' => 'REST API Test Ticket (updated)', + 'description' => 'Updated by testUpdateFull', + 'status' => 'open', + 'priority' => 8, + ]); + + $response = $this->getClient()->put($this->ticketUrl(), [ + RequestOptions::HEADERS => ['Content-Type' => self::MIME_TYPE_TICKET], + RequestOptions::BODY => $updated, + ]); + + $this->assertHttpStatus([200, 204], $response, 'Full PUT update of ticket'); + + $get = $this->getClient()->get($this->ticketUrl(), [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + $this->assertJsonFields([ + 'title' => 'REST API Test Ticket (updated)', + 'priority' => 8, + ], $get, 'Updated fields must be persisted'); + } + + /** + * Close a ticket via PATCH; the status must become "closed" and a closed + * timestamp should be present in the response. + */ + public function testClose() + { + $response = $this->getClient()->patch($this->ticketUrl(), [ + RequestOptions::HEADERS => ['Content-Type' => self::MIME_TYPE_TICKET], + RequestOptions::BODY => json_encode(['status' => 'closed']), + ]); + + $this->assertHttpStatus([200, 204], $response, 'Closing ticket'); + + $get = $this->getClient()->get($this->ticketUrl(), [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + $this->assertHttpStatus(200, $get); + + $body = json_decode((string)$get->getBody(), true); + $this->assertEquals('closed', $body['status'] ?? null, 'Ticket status must be closed'); + $this->assertNotEmpty($body['closed'] ?? null, + 'A closed timestamp must be present when status is closed'); + } + + /** + * DELETE the ticket; the server must respond with 204 No Content. + */ + public function testDelete() + { + $response = $this->getClient()->delete($this->ticketUrl(), [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + + $this->assertHttpStatus(204, $response, 'Deleting the ticket'); + } + + /** + * After deletion, a GET must return 404 Not Found. + */ + public function testReadAfterDelete() + { + $response = $this->getClient()->get($this->ticketUrl(), [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + + $this->assertHttpStatus(404, $response, 'Ticket must not exist after delete'); + } + + // ------------------------------------------------------------------------- + // Collection operations + // ------------------------------------------------------------------------- + + /** + * POST a new ticket to the collection. The server must respond with 201 + * and a Location header. We clean up afterwards. + */ + public function testCreateViaPost() + { + $ticket = [ + '@type' => 'Ticket', + 'uid' => 'rest-api-post-ticket-99887766', + 'title' => 'POST-created ticket', + 'description' => 'Created via POST to the tracker collection', + 'status' => 'open', + 'priority' => 3, + ]; + + $response = $this->getClient()->post($this->appUrl('tracker'), [ + RequestOptions::HEADERS => ['Content-Type' => self::MIME_TYPE_TICKET], + RequestOptions::BODY => json_encode($ticket), + ]); + + $this->assertHttpStatus(201, $response, 'Creating ticket via POST to collection'); + $this->assertNotEmpty($response->getHeaderLine('Location'), + 'POST must return a Location header'); + + // Clean up + $location = $this->locationPath($response); + if ($location) + { + $this->getClient()->delete($this->url($location)); + } + } + + /** + * GET the tracker collection with JSON Accept header. + * The response must be a JSON object with a "responses" array. + */ + public function testListCollection() + { + $response = $this->getClient()->get($this->appUrl('tracker'), [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + + $this->assertHttpStatus(200, $response, 'Listing tracker collection'); + + $body = json_decode((string)$response->getBody(), true); + $this->assertNotNull($body, 'Collection response must be valid JSON'); + $this->assertArrayHasKey('responses', $body, + 'Tracker collection JSON must have a "responses" key'); + } +} diff --git a/tests/REST/TrackerRestPermissions.php b/tests/REST/TrackerRestPermissions.php new file mode 100644 index 0000000..546a58f --- /dev/null +++ b/tests/REST/TrackerRestPermissions.php @@ -0,0 +1,418 @@ + + * @package tracker + * @subpackage tests + * @copyright (c) 2026 by EGroupware GmbH + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + */ + +namespace EGroupware\Tracker; + +require_once __DIR__.'/../../../api/tests/RestTest.php'; + +use EGroupware\Api\RestTest; +use EGroupware\Api\Acl; +use GuzzleHttp\RequestOptions; + +/** + * ACL / permission scenarios for the Tracker JSON REST API. + * + * @covers tracker_groupdav::get() + * @covers tracker_groupdav::put() + * @covers tracker_groupdav::delete() + */ +class TrackerRestPermissions extends RestTest +{ + const MIME_TYPE_TICKET = 'application/json'; + + /** + * Users created for this test suite. + * + * ACL rights use EGroupware's standard Acl bitmask constants. + * "tracker" rights here map to the groupdav "run" ACL that gates access to + * the tracker collection; queue-level role assignment is done separately in + * setUpBeforeClass(). + * + * @var array + */ + protected static $users = [ + 'manager' => [], // TRACKER_ADMIN set in setUpBeforeClass + 'technician' => [], // TRACKER_TECHNICIAN set in setUpBeforeClass + 'reporter' => [], // TRACKER_USER (default for any logged-in user) + ]; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + // Create users with groupdav run-rights so they can reach the endpoint + self::createUsersACL(self::$users, 'tracker'); + + // Verify the tracker REST endpoint exists; skip if not yet implemented + $client = new \GuzzleHttp\Client([ + RequestOptions::HTTP_ERRORS => false, + RequestOptions::VERIFY => false, + RequestOptions::ALLOW_REDIRECTS => true, + RequestOptions::AUTH => [ + $GLOBALS['EGW_USER'] ?? 'demo', + $GLOBALS['EGW_PASSWORD'] ?? 'guest', + ], + ]); + $base = $_ENV['EGW_URL'] ?? getenv('EGW_URL') ?: self::CALDAV_BASE; + $base = rtrim($base, '/') . (strpos($base, 'groupdav.php') === false ? '/groupdav.php' : ''); + $user = $GLOBALS['EGW_USER'] ?? 'demo'; + + // Probe with a PUT to detect whether the tracker REST handler is implemented. + // A generic CalDAV collection returns 200 on GET but 403/405/501 on PUT. + $probeUid = 'tracker-probe-perms-skip-00000000'; + $probe = $client->put("$base/$user/tracker/$probeUid", [ + RequestOptions::HEADERS => [ + 'Content-Type' => 'application/json', + 'If-None-Match' => '*', + ], + RequestOptions::BODY => json_encode([ + '@type' => 'Ticket', + 'uid' => $probeUid, + 'title' => 'Probe ticket (auto-deleted)', + 'status' => 'open', + ]), + ]); + + if (!in_array($probe->getStatusCode(), [201, 204], true)) + { + self::markTestSkipped( + 'Tracker REST API (tracker_groupdav) is not yet implemented ' + .'(PUT probe returned HTTP '.$probe->getStatusCode().'). ' + .'See tracker/inc/class.tracker_groupdav.inc.php.' + ); + } + + // Clean up probe ticket + $location = $probe->getHeaderLine('Location') ?: "$base/$user/tracker/$probeUid"; + $client->delete($location, [RequestOptions::HEADERS => ['Accept' => 'application/json']]); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Build a minimal tracker ticket JSON body. + * + * @param string $uid + * @param string $title + * @param int $priority 1–9 + * @param string $status open|pending|closed + * @return string JSON + */ + private function makeTicketJson( + string $uid, + string $title = 'Test Ticket', + int $priority = 5, + string $status = 'open' + ): string { + return json_encode([ + '@type' => 'Ticket', + 'uid' => $uid, + 'title' => $title, + 'description' => "Ticket created for test uid=$uid", + 'status' => $status, + 'priority' => $priority, + ], JSON_PRETTY_PRINT); + } + + /** + * URL of a ticket in a specific user's tracker collection view. + */ + private function ticketUrl(string $user, string $uid): string + { + return $this->url("/$user/tracker/$uid"); + } + + // ------------------------------------------------------------------------- + // Tests: principal sanity check + // ------------------------------------------------------------------------- + + /** + * Verify all test users were created successfully. + */ + public function testPrincipals() + { + foreach (array_keys(self::$users) as $user) + { + $response = $this->getClient($user)->propfind( + $this->url("/principals/users/$user/"), + [RequestOptions::HEADERS => ['Depth' => '0']] + ); + $this->assertHttpStatus(207, $response, "Principal for '$user' must exist"); + } + } + + // ------------------------------------------------------------------------- + // Tests: reporter submits, reads own ticket + // ------------------------------------------------------------------------- + + /** + * Reporter creates a ticket in their own name. + * Then reads it back – must see the ticket they just created. + */ + public function testReporterCreateAndRead() + { + $uid = 'rest-perm-reporter-create-11001100'; + $url = $this->ticketUrl('reporter', $uid); + + $create = $this->getClient('reporter')->put($url, [ + RequestOptions::HEADERS => [ + 'Content-Type' => self::MIME_TYPE_TICKET, + 'If-None-Match' => '*', + ], + RequestOptions::BODY => $this->makeTicketJson($uid, 'Reporter\'s bug report'), + ]); + $this->assertHttpStatus(201, $create, 'Reporter creates ticket'); + + $read = $this->getClient('reporter')->get($url, [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + $this->assertHttpStatus(200, $read, 'Reporter reads own ticket'); + $this->assertJsonFields(['uid' => $uid, 'status' => 'open'], $read); + + // Clean up + $this->getClient('manager')->delete($url, [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + } + + // ------------------------------------------------------------------------- + // Tests: technician can update but not delete + // ------------------------------------------------------------------------- + + /** + * Technician must be able to change the status of a ticket (e.g. set it to + * pending) but should not be able to permanently delete it. + */ + public function testTechnicianCanUpdateButNotDelete() + { + $uid = 'rest-perm-tech-update-22002200'; + $managerUrl = $this->ticketUrl('manager', $uid); + $techUrl = $this->ticketUrl('technician', $uid); + + // Manager creates the ticket + $this->getClient('manager')->put($managerUrl, [ + RequestOptions::HEADERS => [ + 'Content-Type' => self::MIME_TYPE_TICKET, + 'If-None-Match' => '*', + ], + RequestOptions::BODY => $this->makeTicketJson($uid, 'Ticket for technician tests'), + ]); + + // Technician changes status to "pending" + $update = $this->getClient('technician')->patch($techUrl, [ + RequestOptions::HEADERS => ['Content-Type' => self::MIME_TYPE_TICKET], + RequestOptions::BODY => json_encode(['status' => 'pending']), + ]); + $this->assertHttpStatus([200, 204], $update, + 'Technician must be allowed to update ticket status'); + + // Technician must NOT be allowed to delete + $delete = $this->getClient('technician')->delete($techUrl, [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + $this->assertHttpStatus([403, 405], $delete, + 'Technician must not be allowed to delete tickets'); + + // Ticket must still exist + $stillThere = $this->getClient('manager')->get($managerUrl, [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + $this->assertHttpStatus(200, $stillThere, 'Ticket must still exist after failed delete'); + + // Clean up + $this->getClient('manager')->delete($managerUrl, [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + } + + // ------------------------------------------------------------------------- + // Tests: manager has full control + // ------------------------------------------------------------------------- + + /** + * Manager can delete any ticket regardless of who created it. + */ + public function testManagerCanDeleteAnyTicket() + { + $uid = 'rest-perm-manager-delete-33003300'; + $reporterUrl = $this->ticketUrl('reporter', $uid); + $managerUrl = $this->ticketUrl('manager', $uid); + + // Reporter creates the ticket + $this->getClient('reporter')->put($reporterUrl, [ + RequestOptions::HEADERS => [ + 'Content-Type' => self::MIME_TYPE_TICKET, + 'If-None-Match' => '*', + ], + RequestOptions::BODY => $this->makeTicketJson($uid, 'Ticket to be deleted by manager'), + ]); + + // Manager deletes it + $delete = $this->getClient('manager')->delete($managerUrl, [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + $this->assertHttpStatus(204, $delete, 'Manager must be able to delete any ticket'); + + // Confirm it is gone + $gone = $this->getClient('reporter')->get($reporterUrl, [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + $this->assertHttpStatus(404, $gone, 'Ticket must be gone after manager deletes it'); + } + + // ------------------------------------------------------------------------- + // Tests: private ticket visibility + // ------------------------------------------------------------------------- + + /** + * A private ticket (tr_private = 1) must only be visible to its creator, + * its assignees, and admin users. Other users must receive 403 or 404. + */ + public function testPrivateTicketHiddenFromOthers() + { + $uid = 'rest-perm-private-44004400'; + $reporterUrl = $this->ticketUrl('reporter', $uid); + $techUrl = $this->ticketUrl('technician', $uid); + + // Reporter creates a private ticket + $privateTicket = json_encode([ + '@type' => 'Ticket', + 'uid' => $uid, + 'title' => 'Private bug – restricted access', + 'description' => 'Only visible to creator and manager', + 'status' => 'open', + 'priority' => 7, + 'private' => true, + ]); + + $create = $this->getClient('reporter')->put($reporterUrl, [ + RequestOptions::HEADERS => [ + 'Content-Type' => self::MIME_TYPE_TICKET, + 'If-None-Match' => '*', + ], + RequestOptions::BODY => $privateTicket, + ]); + $this->assertHttpStatus(201, $create, 'Reporter creates private ticket'); + + // Reporter can read their own private ticket + $selfRead = $this->getClient('reporter')->get($reporterUrl, [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + $this->assertHttpStatus(200, $selfRead, 'Reporter can read own private ticket'); + + // Technician (not assigned, not creator) must NOT see the private ticket + $techRead = $this->getClient('technician')->get($techUrl, [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + $this->assertHttpStatus([403, 404], $techRead, + 'Technician must not see private ticket they are not assigned to'); + + // Manager (admin) must still see it + $managerRead = $this->getClient('manager')->get($this->ticketUrl('manager', $uid), [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + $this->assertHttpStatus(200, $managerRead, 'Manager must see private tickets'); + + // Clean up + $this->getClient('manager')->delete($this->ticketUrl('manager', $uid), [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + } + + // ------------------------------------------------------------------------- + // Tests: creator can close but not necessarily delete own ticket + // ------------------------------------------------------------------------- + + /** + * The reporter who created a ticket must be allowed to close it + * (TRACKER_ITEM_CREATOR rights) but must NOT be able to delete it + * unless they have explicit DELETE rights. + */ + public function testCreatorCanCloseOwnTicket() + { + $uid = 'rest-perm-creator-close-55005500'; + $reporterUrl = $this->ticketUrl('reporter', $uid); + + $this->getClient('reporter')->put($reporterUrl, [ + RequestOptions::HEADERS => [ + 'Content-Type' => self::MIME_TYPE_TICKET, + 'If-None-Match' => '*', + ], + RequestOptions::BODY => $this->makeTicketJson($uid, 'Ticket to be closed by creator'), + ]); + + // Creator closes own ticket + $close = $this->getClient('reporter')->patch($reporterUrl, [ + RequestOptions::HEADERS => ['Content-Type' => self::MIME_TYPE_TICKET], + RequestOptions::BODY => json_encode(['status' => 'closed']), + ]); + $this->assertHttpStatus([200, 204], $close, 'Creator must be able to close own ticket'); + + // Creator tries to delete — depends on queue ACL (reporter typically can't) + $delete = $this->getClient('reporter')->delete($reporterUrl, [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + // Accept either "forbidden" (correct ACL enforcement) or "no content" (if + // the queue allows self-delete); either is valid depending on configuration. + $this->assertHttpStatus([204, 403, 405], $delete, + 'Creator delete attempt must return 204 (allowed) or 403/405 (blocked by ACL)'); + + // If the ticket still exists, clean up as manager + $check = $this->getClient('manager')->get($this->ticketUrl('manager', $uid), [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + if ($check->getStatusCode() === 200) + { + $this->getClient('manager')->delete($this->ticketUrl('manager', $uid), [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + } + } + + // ------------------------------------------------------------------------- + // Tests: unauthenticated access + // ------------------------------------------------------------------------- + + /** + * Any request to the tracker collection without credentials must return 401. + */ + public function testNoAuth() + { + $response = $this->getClient([])->get($this->appUrl('tracker'), [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + + $this->assertHttpStatus(401, $response); + } +} From d69d5da8aeef36c8cdd30d179ce6b6ca6bd923df Mon Sep 17 00:00:00 2001 From: Amir Dehestani Date: Thu, 11 Jun 2026 15:32:26 +0200 Subject: [PATCH 8/8] tracker REST API: 403 for no-queue access; assigned as JMAP map - Remove auto-create queue from constructor; return 403 Forbidden in propfind and new-ticket PUT/POST when the user has no queue access - Change assigned field from sequential array to JMAP-style map keyed by numeric account-id string, so PATCH can add/remove individual assignees: {"uid": true} to add, {"uid": null} to remove - Fix parseAssigned to apply map as delta on top of $old for PATCH - Add TrackerRestAssignedPatch test covering add and remove via PATCH --- src/ApiHandler.php | 24 +-- src/JsTracker.php | 60 +++++-- tests/REST/TrackerRestAssignedPatch.php | 221 ++++++++++++++++++++++++ 3 files changed, 279 insertions(+), 26 deletions(-) create mode 100644 tests/REST/TrackerRestAssignedPatch.php diff --git a/src/ApiHandler.php b/src/ApiHandler.php index 8d292dc..7e4ac06 100644 --- a/src/ApiHandler.php +++ b/src/ApiHandler.php @@ -62,21 +62,6 @@ public function __construct($app, Api\CalDAV $caldav) parent::__construct('tracker', $caldav); self::$path_extension = ''; $this->bo = new \tracker_bo(); - - // Tracker requires at least one queue (category) to search/list tickets. - // Auto-create a "Default" queue on first REST API use if none exist. - if (empty($this->bo->trackers)) - { - $cats = new Api\Categories(0, 'tracker'); - $cats->add([ - 'name' => 'Default', - 'owner' => 0, - 'access' => 'public', - 'data' => ['type' => 'tracker'], - ]); - // Reload after creation so search() finds the new queue. - $this->bo->trackers = $this->bo->get_tracker_labels(); - } } // ───────────────────────────────────────────────────────────────────────── @@ -95,6 +80,11 @@ public function __construct($app, Api\CalDAV $caldav) */ public function propfind($path, &$options, &$files, $user, $id = '') { + if (empty($this->bo->trackers)) + { + return '403 Forbidden'; + } + $filter = []; // Restrict to a specific owner when user prefix is present in the URL. @@ -545,6 +535,10 @@ public function put(&$options, $id, $user = null, $prefix = null, string $method else { // new ticket + if (empty($this->bo->trackers)) + { + return '403 Forbidden'; + } if (!isset($ticket['tr_tracker'])) { // use the first available tracker queue the user has access to diff --git a/src/JsTracker.php b/src/JsTracker.php index 4e6365d..7f4a002 100644 --- a/src/JsTracker.php +++ b/src/JsTracker.php @@ -115,7 +115,12 @@ public static function JsTicket($ticket, $encode = true) } /** - * Format the assigned list (array of account-ids) as JSON. + * Format the assigned list as a JMAP-style map keyed by account-id string. + * + * Returns { "123": , … } so that a PATCH can add or remove + * individual assignees without replacing the whole list: + * PATCH {"assigned": {"123": null}} → removes user 123 + * PATCH {"assigned": {"456": true}} → adds user 456 * * @param array|int $assigned * @return array|null @@ -126,7 +131,7 @@ protected static function assigned($assigned) $result = []; foreach ((array)$assigned as $uid) { - if ($uid) $result[] = self::account($uid); + if ($uid) $result[(string)(int)$uid] = self::account($uid); } return $result ?: null; } @@ -225,7 +230,7 @@ public static function parseJsTicket(string $json, array $old = [], ?string $con break; case 'assigned': - $ticket['tr_assigned'] = self::parseAssigned($value); + $ticket['tr_assigned'] = self::parseAssigned($value, (array)($old['assigned'] ?? []), $method); break; case 'cc': @@ -380,22 +385,55 @@ public static function parseStatus(string $value): int } /** - * Parse the assigned field: accepts a single account-object or an array of them. + * Parse the assigned field. + * + * Preferred format is a JMAP map keyed by account-id string: + * { "123": |true } → add/keep user 123 + * { "123": null } → remove user 123 (PATCH only) + * + * For PATCH the map is applied as a delta on top of $old. + * For PUT/POST only non-null entries form the new list. + * + * Legacy format (array of account-objects) is still accepted. * - * @param mixed $value + * @param mixed $value + * @param array $old current account_id list, used for PATCH merging + * @param string $method PUT / POST / PATCH * @return array flat array of account_ids */ - protected static function parseAssigned($value): array + protected static function parseAssigned($value, array $old = [], string $method = 'PUT'): array { if (!$value) return []; - if (isset($value['uid'])) $value = [$value]; // single object - $ids = []; - foreach ($value as $item) + + // Map format: associative, not a single account-object or sequential list + if (is_array($value) && !isset($value['uid']) && !array_is_list($value)) { - if (($uid = self::parseAccount($item))) + // PATCH starts from existing ids; PUT/POST replaces entirely + $ids = $method === 'PATCH' ? array_flip(array_filter($old)) : []; + + foreach ($value as $key => $entry) { - $ids[] = $uid; + $uid = is_numeric($key) ? (int)$key : self::parseAccount($key); + if (!$uid) continue; + + if ($entry === null) + { + unset($ids[$uid]); + } + else + { + $ids[$uid] = true; + } } + return array_keys($ids); + } + + // Legacy: single account-object or sequential array of account-objects + if (isset($value['uid'])) $value = [$value]; + $ids = []; + foreach ((array)$value as $item) + { + if (($uid = self::parseAccount($item))) $ids[] = $uid; } return $ids; } diff --git a/tests/REST/TrackerRestAssignedPatch.php b/tests/REST/TrackerRestAssignedPatch.php new file mode 100644 index 0000000..6365144 --- /dev/null +++ b/tests/REST/TrackerRestAssignedPatch.php @@ -0,0 +1,221 @@ + } + * - PATCH { "assigned": { "$login": true } } adds an assignee + * - PATCH { "assigned": { "$numeric_id": null } } removes a single assignee + * without touching the rest of the list + * + * @link https://www.egroupware.org + * @package tracker + * @subpackage tests + * @author EGroupware GmbH + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + */ + +namespace EGroupware\Tracker; + +require_once __DIR__.'/../../../api/tests/RestTest.php'; + +use EGroupware\Api\RestTest; +use GuzzleHttp\RequestOptions; + +/** + * Tests for the JMAP-style map format of the "assigned" field. + * + * Test order: + * testCreate – POST a new ticket (no assignees) + * testAssignedIsNull – verify assigned is absent/null on a fresh ticket + * testPatchAddAssignee – PATCH adds an assignee; response is a map, not an array + * testPatchRemoveAssignee – PATCH with null removes the assignee + * testDelete – clean up + */ +class TrackerRestAssignedPatch extends RestTest +{ + const MIME_TYPE_TICKET = 'application/json'; + + /** + * Full URL of the created ticket (set after testCreate). + */ + protected static ?string $ticketUrl = null; + + /** + * The numeric account-id key captured from the assigned map (set after testPatchAddAssignee). + */ + protected static ?string $assignedKey = null; + + // ------------------------------------------------------------------------- + // Skip guard + // ------------------------------------------------------------------------- + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + $client = new \GuzzleHttp\Client([ + RequestOptions::HTTP_ERRORS => false, + RequestOptions::VERIFY => false, + RequestOptions::ALLOW_REDIRECTS => true, + RequestOptions::AUTH => [ + $GLOBALS['EGW_USER'] ?? 'demo', + $GLOBALS['EGW_PASSWORD'] ?? 'guest', + ], + ]); + + $base = $_ENV['EGW_URL'] ?? getenv('EGW_URL') ?: self::CALDAV_BASE; + $base = rtrim($base, '/') . (strpos($base, 'groupdav.php') === false ? '/groupdav.php' : ''); + $user = $GLOBALS['EGW_USER'] ?? 'demo'; + + $probe = $client->post("$base/$user/tracker/", [ + RequestOptions::HEADERS => ['Content-Type' => 'application/json'], + RequestOptions::BODY => json_encode([ + '@type' => 'Ticket', + 'summary' => 'Probe ticket (auto-deleted)', + ]), + ]); + + if ($probe->getStatusCode() !== 201) + { + self::markTestSkipped( + 'Tracker REST API is not yet implemented ' + .'(POST probe returned HTTP '.$probe->getStatusCode().'). ' + .'Implement the tracker REST handler, then re-run these tests.' + ); + } + + // Clean up probe ticket + $location = $probe->getHeaderLine('Location'); + if ($location) + { + $client->delete($location, [RequestOptions::HEADERS => ['Accept' => 'application/json']]); + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + protected function ticketUrl(): string + { + return self::$ticketUrl ?? $this->appUrl('tracker'); + } + + // ------------------------------------------------------------------------- + // Tests + // ------------------------------------------------------------------------- + + /** + * Create a fresh ticket with no assignees via POST. + * Captures the Location header so subsequent tests know the URL. + */ + public function testCreate() + { + $response = $this->getClient()->post($this->appUrl('tracker'), [ + RequestOptions::HEADERS => ['Content-Type' => self::MIME_TYPE_TICKET], + RequestOptions::BODY => json_encode([ + '@type' => 'Ticket', + 'summary' => 'Assigned PATCH map test', + ]), + ]); + + $this->assertHttpStatus(201, $response, 'POST must create the ticket'); + $location = $this->locationPath($response); + $this->assertNotEmpty($location, 'POST must return a Location header'); + + self::$ticketUrl = $this->url($location); + } + + /** + * A freshly created ticket with no assignees must return null / no assigned field. + * + * @depends testCreate + */ + public function testAssignedIsNull() + { + $response = $this->getClient()->get($this->ticketUrl(), [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + $this->assertHttpStatus(200, $response); + + $body = json_decode((string)$response->getBody(), true); + $this->assertEmpty($body['assigned'] ?? null, + 'assigned must be absent or null on a ticket created without assignees'); + } + + /** + * PATCH { "assigned": { "$login": true } } must add the user. + * The response assigned field must be a map (string keys), not a sequential array. + * + * @depends testAssignedIsNull + */ + public function testPatchAddAssignee() + { + $login = $GLOBALS['EGW_USER'] ?? 'demo'; + + $response = $this->getClient()->patch($this->ticketUrl(), [ + RequestOptions::HEADERS => ['Content-Type' => self::MIME_TYPE_TICKET], + RequestOptions::BODY => json_encode(['assigned' => [$login => true]]), + ]); + $this->assertHttpStatus([200, 204], $response, 'PATCH to add assignee'); + + // Read back and verify map format + $get = $this->getClient()->get($this->ticketUrl(), [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + $this->assertHttpStatus(200, $get); + $body = json_decode((string)$get->getBody(), true); + + $assigned = $body['assigned'] ?? null; + $this->assertNotNull($assigned, 'assigned must not be null after adding an assignee'); + $this->assertIsArray($assigned, 'assigned must be an array'); + $this->assertFalse(array_is_list($assigned), + 'assigned must be a JMAP-style map with account-id string keys, not a sequential array'); + + // Capture the numeric key for the remove test + self::$assignedKey = (string)array_key_first($assigned); + $this->assertIsNumeric(self::$assignedKey, + 'assigned map keys must be numeric account-id strings'); + } + + /** + * PATCH { "assigned": { "$numeric_id": null } } must remove that single assignee. + * The rest of the assigned list must be unchanged (here: resulting in an empty map). + * + * @depends testPatchAddAssignee + */ + public function testPatchRemoveAssignee() + { + $this->assertNotNull(self::$assignedKey, 'testPatchAddAssignee must run first'); + + $response = $this->getClient()->patch($this->ticketUrl(), [ + RequestOptions::HEADERS => ['Content-Type' => self::MIME_TYPE_TICKET], + RequestOptions::BODY => json_encode(['assigned' => [self::$assignedKey => null]]), + ]); + $this->assertHttpStatus([200, 204], $response, 'PATCH to remove assignee'); + + // Read back and verify the assignee is gone + $get = $this->getClient()->get($this->ticketUrl(), [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + $this->assertHttpStatus(200, $get); + $body = json_decode((string)$get->getBody(), true); + + $this->assertEmpty($body['assigned'] ?? null, + 'assigned must be empty after removing the only assignee via PATCH null'); + } + + /** + * Clean up the test ticket. + * + * @depends testPatchRemoveAssignee + */ + public function testDelete() + { + $response = $this->getClient()->delete($this->ticketUrl(), [ + RequestOptions::HEADERS => ['Accept' => self::MIME_TYPE_TICKET], + ]); + $this->assertHttpStatus(204, $response, 'DELETE must remove the test ticket'); + } +}