Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F2571704
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
118 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/app/Backends/DAV.php b/src/app/Backends/DAV.php
index 992f2572..0028e38f 100644
--- a/src/app/Backends/DAV.php
+++ b/src/app/Backends/DAV.php
@@ -1,508 +1,509 @@
<?php
namespace App\Backends;
use Illuminate\Support\Facades\Http;
class DAV
{
public const TYPE_VEVENT = 'VEVENT';
public const TYPE_VTODO = 'VTODO';
public const TYPE_VCARD = 'VCARD';
protected const NAMESPACES = [
self::TYPE_VEVENT => 'urn:ietf:params:xml:ns:caldav',
self::TYPE_VTODO => 'urn:ietf:params:xml:ns:caldav',
self::TYPE_VCARD => 'urn:ietf:params:xml:ns:carddav',
];
protected $url;
protected $user;
protected $password;
protected $responseHeaders = [];
/**
* Object constructor
*/
public function __construct($user, $password)
{
$this->url = \config('services.dav.uri');
$this->user = $user;
$this->password = $password;
}
/**
* Discover DAV home (root) collection of a specified type.
*
* @param string $component Component to filter by (VEVENT, VTODO, VCARD)
*
* @return string|false Home collection location or False on error
*/
public function discover(string $component = self::TYPE_VEVENT)
{
$roots = [
self::TYPE_VEVENT => 'calendars',
self::TYPE_VTODO => 'calendars',
self::TYPE_VCARD => 'addressbooks',
];
$homes = [
self::TYPE_VEVENT => 'calendar-home-set',
self::TYPE_VTODO => 'calendar-home-set',
self::TYPE_VCARD => 'addressbook-home-set',
];
$path = parse_url($this->url, PHP_URL_PATH);
$body = '<?xml version="1.0" encoding="utf-8"?>'
. '<d:propfind xmlns:d="DAV:">'
. '<d:prop>'
. '<d:current-user-principal />'
. '</d:prop>'
. '</d:propfind>';
// Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it)
$headers = ['Depth' => 1, 'Prefer' => 'return-minimal'];
$response = $this->request('/' . $roots[$component], 'PROPFIND', $body, $headers);
if (empty($response)) {
\Log::error("Failed to get current-user-principal for {$component} from the DAV server.");
return false;
}
$elements = $response->getElementsByTagName('response');
foreach ($elements as $element) {
foreach ($element->getElementsByTagName('prop') as $prop) {
$principal_href = $prop->nodeValue;
break;
}
}
if (empty($principal_href)) {
\Log::error("No principal on the DAV server.");
return false;
}
if ($path && strpos($principal_href, $path) === 0) {
$principal_href = substr($principal_href, strlen($path));
}
$body = '<?xml version="1.0" encoding="utf-8"?>'
. '<d:propfind xmlns:d="DAV:" xmlns:c="' . self::NAMESPACES[$component] . '">'
. '<d:prop>'
. '<c:' . $homes[$component] . ' />'
. '</d:prop>'
. '</d:propfind>';
$response = $this->request($principal_href, 'PROPFIND', $body);
if (empty($response)) {
\Log::error("Failed to get homes for {$component} from the DAV server.");
return false;
}
$root_href = false;
$elements = $response->getElementsByTagName('response');
foreach ($elements as $element) {
foreach ($element->getElementsByTagName('prop') as $prop) {
$root_href = $prop->nodeValue;
break;
}
}
if (!empty($root_href)) {
if ($path && strpos($root_href, $path) === 0) {
$root_href = substr($root_href, strlen($path));
}
}
return $root_href;
}
/**
* Check if we can connect to the DAV server
*
* @return bool True on success, False otherwise
*/
public static function healthcheck(): bool
{
// TODO
return true;
}
/**
* Get list of folders of specified type.
*
* @param string $component Component to filter by (VEVENT, VTODO, VCARD)
*
* @return false|array<DAV\Folder> List of folders' metadata or False on error
*/
public function listFolders(string $component)
{
$root_href = $this->discover($component);
if ($root_href === false) {
return false;
}
$ns = 'xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"';
$props = '';
if ($component != self::TYPE_VCARD) {
$ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/" xmlns:k="Kolab:"';
$props = '<c:supported-calendar-component-set />'
. '<a:calendar-color />'
. '<k:alarms />';
}
$body = '<?xml version="1.0" encoding="utf-8"?>'
. '<d:propfind ' . $ns . '>'
. '<d:prop>'
. '<d:resourcetype />'
. '<d:displayname />'
. '<cs:getctag />'
. $props
. '</d:prop>'
. '</d:propfind>';
// Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it)
$headers = ['Depth' => 1, 'Prefer' => 'return-minimal'];
$response = $this->request($root_href, 'PROPFIND', $body, $headers);
if (empty($response)) {
\Log::error("Failed to get folders list from the DAV server.");
return false;
}
$folders = [];
foreach ($response->getElementsByTagName('response') as $element) {
$folder = DAV\Folder::fromDomElement($element);
// Note: Addressbooks don't have 'type' specified
- if (($component == self::TYPE_VCARD && in_array('addressbook', $folder->types))
+ if (
+ ($component == self::TYPE_VCARD && in_array('addressbook', $folder->types))
|| in_array($component, $folder->components)
) {
$folders[] = $folder;
}
}
return $folders;
}
/**
* Create a DAV object in a folder
*
* @param DAV\CommonObject $object Object
*
* @return false|DAV\CommonObject Object on success, False on error
*/
public function create(DAV\CommonObject $object)
{
$headers = ['Content-Type' => $object->contentType];
$response = $this->request($object->href, 'PUT', $object, $headers);
if ($response !== false) {
if ($etag = $this->responseHeaders['etag']) {
if (preg_match('|^".*"$|', $etag)) {
$etag = substr($etag, 1, -1);
}
$object->etag = $etag;
}
return $object;
}
return false;
}
/**
* Update a DAV object in a folder
*
* @param DAV\CommonObject $object Object
*
* @return false|DAV\CommonObject Object on success, False on error
*/
public function update(DAV\CommonObject $object)
{
return $this->create($object);
}
/**
* Delete a DAV object from a folder
*
* @param string $location Object location
*
* @return bool True on success, False on error
*/
public function delete(string $location)
{
$response = $this->request($location, 'DELETE', '', ['Depth' => 1, 'Prefer' => 'return-minimal']);
return $response !== false;
}
/**
* Get all properties of a folder.
*
* @param string $location Object location
*
* @return false|DAV\Folder Folder metadata or False on error
*/
public function folderInfo(string $location)
{
$body = '<?xml version="1.0" encoding="utf-8"?>'
. '<d:propfind xmlns:d="DAV:">'
. '<d:allprop/>'
. '</d:propfind>';
// Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it)
$headers = ['Depth' => 1, 'Prefer' => 'return-minimal'];
$response = $this->request($location, 'PROPFIND', $body, $headers);
if (!empty($response) && ($element = $response->getElementsByTagName('response')->item(0))) {
return DAV\Folder::fromDomElement($element);
}
return false;
}
/**
* Search DAV objects in a folder.
*
* @param string $location Folder location
* @param string $component Object type (VEVENT, VTODO, VCARD)
*
* @return false|array Objects metadata on success, False on error
*/
public function search(string $location, string $component)
{
$queries = [
self::TYPE_VEVENT => 'calendar-query',
self::TYPE_VTODO => 'calendar-query',
self::TYPE_VCARD => 'addressbook-query',
];
$filter = '';
if ($component != self::TYPE_VCARD) {
$filter = '<c:comp-filter name="VCALENDAR">'
. '<c:comp-filter name="' . $component . '" />'
. '</c:comp-filter>';
}
// TODO: Make filter an argument of this function to build all kind of queries.
// It probably should be a separate object e.g. DAV\Filter.
// TODO: List of object props to return should also be an argument, so we not only
// could fetch "an index" but also any of object's data.
$body = '<?xml version="1.0" encoding="utf-8"?>'
- .' <c:' . $queries[$component] . ' xmlns:d="DAV:" xmlns:c="' . self::NAMESPACES[$component] . '">'
+ . ' <c:' . $queries[$component] . ' xmlns:d="DAV:" xmlns:c="' . self::NAMESPACES[$component] . '">'
. '<d:prop>'
. '<d:getetag />'
. '</d:prop>'
. ($filter ? "<c:filter>$filter</c:filter>" : '')
. '</c:' . $queries[$component] . '>';
$response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
if (empty($response)) {
\Log::error("Failed to get objects from the DAV server.");
return false;
}
$objects = [];
foreach ($response->getElementsByTagName('response') as $element) {
$objects[] = $this->objectFromElement($element, $component);
}
return $objects;
}
/**
* Fetch DAV objects data from a folder
*
* @param string $location Folder location
* @param string $component Object type (VEVENT, VTODO, VCARD)
* @param array $hrefs List of objects' locations to fetch (empty for all objects)
*
* @return false|array Objects metadata on success, False on error
*/
public function getObjects(string $location, string $component, array $hrefs = [])
{
if (empty($hrefs)) {
return [];
}
$body = '';
foreach ($hrefs as $href) {
$body .= '<d:href>' . $href . '</d:href>';
}
$queries = [
self::TYPE_VEVENT => 'calendar-multiget',
self::TYPE_VTODO => 'calendar-multiget',
self::TYPE_VCARD => 'addressbook-multiget',
];
$types = [
self::TYPE_VEVENT => 'calendar-data',
self::TYPE_VTODO => 'calendar-data',
self::TYPE_VCARD => 'address-data',
];
$body = '<?xml version="1.0" encoding="utf-8"?>'
- .' <c:' . $queries[$component] . ' xmlns:d="DAV:" xmlns:c="' . self::NAMESPACES[$component] . '">'
+ . ' <c:' . $queries[$component] . ' xmlns:d="DAV:" xmlns:c="' . self::NAMESPACES[$component] . '">'
. '<d:prop>'
. '<d:getetag />'
. '<c:' . $types[$component] . ' />'
. '</d:prop>'
. $body
. '</c:' . $queries[$component] . '>';
$response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
if (empty($response)) {
\Log::error("Failed to get objects from the DAV server.");
return false;
}
$objects = [];
foreach ($response->getElementsByTagName('response') as $element) {
$objects[] = $this->objectFromElement($element, $component);
}
return $objects;
}
/**
* Parse XML content
*/
protected function parseXML($xml)
{
$doc = new \DOMDocument('1.0', 'UTF-8');
if (stripos($xml, '<?xml') === 0) {
if (!$doc->loadXML($xml)) {
throw new \Exception("Failed to parse XML");
}
$doc->formatOutput = true;
}
return $doc;
}
/**
* Parse request/response body for debug purposes
*/
protected function debugBody($body, $headers)
{
$head = '';
foreach ($headers as $header_name => $header_value) {
if (is_array($header_value)) {
$header_value = implode("\n\t", $header_value);
}
$head .= "{$header_name}: {$header_value}\n";
}
if (stripos($body, '<?xml') === 0) {
$doc = new \DOMDocument('1.0', 'UTF-8');
$doc->formatOutput = true;
$doc->preserveWhiteSpace = false;
if (!$doc->loadXML($body)) {
throw new \Exception("Failed to parse XML");
}
$body = $doc->saveXML();
}
return $head . "\n" . rtrim($body);
}
/**
* Create DAV\CommonObject from a DOMElement
*/
protected function objectFromElement($element, $component)
{
switch ($component) {
case self::TYPE_VEVENT:
$object = DAV\Vevent::fromDomElement($element);
break;
case self::TYPE_VTODO:
$object = DAV\Vtodo::fromDomElement($element);
break;
case self::TYPE_VCARD:
$object = DAV\Vcard::fromDomElement($element);
break;
default:
throw new \Exception("Unknown component: {$component}");
}
return $object;
}
/**
* Execute HTTP request to a DAV server
*/
protected function request($path, $method, $body = '', $headers = [])
{
$debug = \config('app.debug');
$url = $this->url;
$this->responseHeaders = [];
if ($path && ($rootPath = parse_url($url, PHP_URL_PATH)) && strpos($path, $rootPath) === 0) {
$path = substr($path, strlen($rootPath));
}
$url .= $path;
$client = Http::withBasicAuth($this->user, $this->password);
// $client = Http::withToken($token); // Bearer token
if ($body) {
if (!isset($headers['Content-Type'])) {
$headers['Content-Type'] = 'application/xml; charset=utf-8';
}
$client->withBody($body, $headers['Content-Type']);
}
if (!empty($headers)) {
$client->withHeaders($headers);
}
if ($debug) {
\Log::debug("C: {$method}: {$url}\n" . $this->debugBody($body, $headers));
}
$response = $client->send($method, $url);
$body = $response->body();
$code = $response->status();
if ($debug) {
\Log::debug("S: [{$code}]\n" . $this->debugBody($body, $response->headers()));
}
// Throw an exception if a client or server error occurred...
$response->throw();
$this->responseHeaders = $response->headers();
return $this->parseXML($body);
}
}
diff --git a/src/app/Backends/DAV/CommonObject.php b/src/app/Backends/DAV/CommonObject.php
index dfb96aee..16d2ee82 100644
--- a/src/app/Backends/DAV/CommonObject.php
+++ b/src/app/Backends/DAV/CommonObject.php
@@ -1,58 +1,58 @@
<?php
namespace App\Backends\DAV;
class CommonObject
{
/** @var string Object content type (of the string representation) */
public $contentType = '';
/** @var ?string Object ETag (getetag property) */
public $etag;
/** @var ?string Object location (href property) */
public $href;
/** @var ?string Object UID */
public $uid;
/**
* Create DAV object from a DOMElement element
*
* @param \DOMElement $element DOM element with object properties
*
* @return CommonObject
*/
public static function fromDomElement(\DOMElement $element)
{
$object = new static(); // @phpstan-ignore-line
if ($href = $element->getElementsByTagName('href')->item(0)) {
$object->href = $href->nodeValue;
// Extract UID from the URL
$href_parts = explode('/', $object->href);
- $object->uid = preg_replace('/\.[a-z]+$/', '', $href_parts[count($href_parts)-1]);
+ $object->uid = preg_replace('/\.[a-z]+$/', '', $href_parts[count($href_parts) - 1]);
}
if ($etag = $element->getElementsByTagName('getetag')->item(0)) {
$object->etag = $etag->nodeValue;
if (preg_match('|^".*"$|', $object->etag)) {
$object->etag = substr($object->etag, 1, -1);
}
}
return $object;
}
/**
* Create string representation of the DAV object
*
* @return string
*/
public function __toString()
{
return '';
}
}
diff --git a/src/app/Backends/DAV/Vevent.php b/src/app/Backends/DAV/Vevent.php
index c02beec7..d2b7b627 100644
--- a/src/app/Backends/DAV/Vevent.php
+++ b/src/app/Backends/DAV/Vevent.php
@@ -1,282 +1,279 @@
<?php
namespace App\Backends\DAV;
use Illuminate\Support\Str;
use Sabre\VObject;
class Vevent extends CommonObject
{
/** @var string Object content type (of the string representation) */
public $contentType = 'text/calendar; charset=utf-8';
public $attendees = [];
public $comment;
public $description;
public $location;
public $organizer;
public $recurrence = [];
public $sequence;
public $status;
public $summary;
public $transp;
public $url;
public $valarms = [];
public $dtstart;
public $dtend;
public $due;
public $created;
public $lastModified;
public $dtstamp;
/**
* Create event object from a DOMElement element
*
* @param \DOMElement $element DOM element with object properties
*
* @return CommonObject
*/
public static function fromDomElement(\DOMElement $element)
{
/** @var self $object */
$object = parent::fromDomElement($element);
if ($data = $element->getElementsByTagName('calendar-data')->item(0)) {
$object->fromIcal($data->nodeValue);
}
return $object;
}
/**
* Set object properties from an iCalendar
*
* @param string $ical iCalendar string
*/
protected function fromIcal(string $ical): void
{
$options = VObject\Reader::OPTION_FORGIVING | VObject\Reader::OPTION_IGNORE_INVALID_LINES;
$vobject = VObject\Reader::read($ical, $options);
if ($vobject->name != 'VCALENDAR') {
return;
}
$selfType = strtoupper(class_basename(get_class($this)));
foreach ($vobject->getComponents() as $component) {
if ($component->name == $selfType) {
$this->fromVObject($component);
return;
}
}
}
/**
* Set object properties from a Sabre/VObject component object
*
* @param VObject\Component $vobject Sabre/VObject component
*/
protected function fromVObject(VObject\Component $vobject): void
{
$string_properties = [
'COMMENT',
'DESCRIPTION',
'LOCATION',
'SEQUENCE',
'STATUS',
'SUMMARY',
'TRANSP',
'UID',
'URL',
];
// map string properties
foreach ($string_properties as $prop) {
if (isset($vobject->{$prop})) {
$key = Str::camel(strtolower($prop));
$this->{$key} = (string) $vobject->{$prop};
}
}
// map other properties
foreach ($vobject->children() as $prop) {
if (!($prop instanceof VObject\Property)) {
continue;
}
switch ($prop->name) {
- case 'DTSTART':
- case 'DTEND':
- case 'DUE':
- case 'CREATED':
- case 'LAST-MODIFIED':
- case 'DTSTAMP':
- $key = Str::camel(strtolower($prop->name));
- // These are of type Sabre\VObject\Property\ICalendar\DateTime
- $this->{$key} = $prop;
- break;
-
- case 'RRULE':
- $params = !empty($this->recurrence) ? $this->recurrence : [];
-
- foreach ($prop->getParts() as $k => $v) {
- $params[Str::camel(strtolower($k))] = is_array($v) ? implode(',', $v) : $v;
- }
+ case 'DTSTART':
+ case 'DTEND':
+ case 'DUE':
+ case 'CREATED':
+ case 'LAST-MODIFIED':
+ case 'DTSTAMP':
+ $key = Str::camel(strtolower($prop->name));
+ // These are of type Sabre\VObject\Property\ICalendar\DateTime
+ $this->{$key} = $prop;
+ break;
- if (!empty($params['until'])) {
- $params['until'] = new \DateTime($params['until']);
- }
+ case 'RRULE':
+ $params = !empty($this->recurrence) ? $this->recurrence : [];
- if (empty($params['interval'])) {
- $params['interval'] = 1;
- }
+ foreach ($prop->getParts() as $k => $v) {
+ $params[Str::camel(strtolower($k))] = is_array($v) ? implode(',', $v) : $v;
+ }
- $this->recurrence = array_filter($params);
- break;
+ if (!empty($params['until'])) {
+ $params['until'] = new \DateTime($params['until']);
+ }
- case 'EXDATE':
- case 'RDATE':
- $key = strtolower($prop->name);
- $dates = []; // TODO
+ if (empty($params['interval'])) {
+ $params['interval'] = 1;
+ }
- if (!empty($this->recurrence[$key])) {
- $this->recurrence[$key] = array_merge($this->recurrence[$key], $dates);
- }
- else {
- $this->recurrence[$key] = $dates;
- }
+ $this->recurrence = array_filter($params);
+ break;
- break;
-
- case 'ATTENDEE':
- case 'ORGANIZER':
- $attendee = [
- 'rsvp' => false,
- 'email' => preg_replace('!^mailto:!i', '', (string) $prop),
- ];
-
- $attendeeProps = ['CN', 'PARTSTAT', 'ROLE', 'CUTYPE', 'RSVP', 'DELEGATED-FROM', 'DELEGATED-TO',
- 'SCHEDULE-STATUS', 'SCHEDULE-AGENT', 'SENT-BY'];
-
- foreach ($prop->parameters() as $name => $value) {
- $key = Str::camel(strtolower($name));
- switch ($name) {
- case 'RSVP':
- $params[$key] = strtolower($value) == 'true';
- break;
- case 'CN':
- $params[$key] = str_replace('\,', ',', strval($value));
- break;
- default:
- if (in_array($name, $attendeeProps)) {
- $params[$key] = strval($value);
- }
- break;
+ case 'EXDATE':
+ case 'RDATE':
+ $key = strtolower($prop->name);
+ $dates = []; // TODO
+
+ if (!empty($this->recurrence[$key])) {
+ $this->recurrence[$key] = array_merge($this->recurrence[$key], $dates);
+ } else {
+ $this->recurrence[$key] = $dates;
}
- }
- if ($prop->name == 'ORGANIZER') {
- $attendee['role'] = 'ORGANIZER';
- $attendee['partstat'] = 'ACCEPTED';
+ break;
- $this->organizer = $attendee;
- }
- else if (empty($this->organizer) || $attendee['email'] != $this->organizer['email']) {
- $this->attendees[] = $attendee;
- }
+ case 'ATTENDEE':
+ case 'ORGANIZER':
+ $attendee = [
+ 'rsvp' => false,
+ 'email' => preg_replace('!^mailto:!i', '', (string) $prop),
+ ];
+
+ $attendeeProps = ['CN', 'PARTSTAT', 'ROLE', 'CUTYPE', 'RSVP', 'DELEGATED-FROM', 'DELEGATED-TO',
+ 'SCHEDULE-STATUS', 'SCHEDULE-AGENT', 'SENT-BY'];
+
+ foreach ($prop->parameters() as $name => $value) {
+ $key = Str::camel(strtolower($name));
+ switch ($name) {
+ case 'RSVP':
+ $params[$key] = strtolower($value) == 'true';
+ break;
+ case 'CN':
+ $params[$key] = str_replace('\,', ',', strval($value));
+ break;
+ default:
+ if (in_array($name, $attendeeProps)) {
+ $params[$key] = strval($value);
+ }
+ break;
+ }
+ }
+
+ if ($prop->name == 'ORGANIZER') {
+ $attendee['role'] = 'ORGANIZER';
+ $attendee['partstat'] = 'ACCEPTED';
+
+ $this->organizer = $attendee;
+ } elseif (empty($this->organizer) || $attendee['email'] != $this->organizer['email']) {
+ $this->attendees[] = $attendee;
+ }
- break;
+ break;
}
}
// Check DURATION property if no end date is set
/*
if (empty($this->dtend) && !empty($this->dtstart) && !empty($vobject->DURATION)) {
try {
$duration = new \DateInterval((string) $vobject->DURATION);
$end = clone $this->dtstart;
$end->add($duration);
$this->dtend = $end;
}
catch (\Exception $e) {
// TODO: Error?
}
}
*/
// Find alarms
foreach ($vobject->select('VALARM') as $valarm) {
$action = 'DISPLAY';
$trigger = null;
$alarm = [];
foreach ($valarm->children() as $prop) {
$value = strval($prop);
switch ($prop->name) {
- case 'TRIGGER':
- foreach ($prop->parameters as $param) {
- if ($param->name == 'VALUE' && $param->getValue() == 'DATE-TIME') {
- $trigger = '@' . $prop->getDateTime()->format('U');
- $alarm['trigger'] = $prop->getDateTime();
+ case 'TRIGGER':
+ foreach ($prop->parameters as $param) {
+ if ($param->name == 'VALUE' && $param->getValue() == 'DATE-TIME') {
+ $trigger = '@' . $prop->getDateTime()->format('U');
+ $alarm['trigger'] = $prop->getDateTime();
+ } elseif ($param->name == 'RELATED') {
+ $alarm['related'] = $param->getValue();
+ }
}
- else if ($param->name == 'RELATED') {
- $alarm['related'] = $param->getValue();
+ /*
+ if (!$trigger && ($values = libcalendaring::parse_alarm_value($value))) {
+ $trigger = $values[2];
}
- }
-/*
- if (!$trigger && ($values = libcalendaring::parse_alarm_value($value))) {
- $trigger = $values[2];
- }
-*/
- if (empty($alarm['trigger'])) {
- $alarm['trigger'] = rtrim(preg_replace('/([A-Z])0[WDHMS]/', '\\1', $value), 'T');
- // if all 0-values have been stripped, assume 'at time'
- if ($alarm['trigger'] == 'P') {
- $alarm['trigger'] = 'PT0S';
+ */
+ if (empty($alarm['trigger'])) {
+ $alarm['trigger'] = rtrim(preg_replace('/([A-Z])0[WDHMS]/', '\\1', $value), 'T');
+ // if all 0-values have been stripped, assume 'at time'
+ if ($alarm['trigger'] == 'P') {
+ $alarm['trigger'] = 'PT0S';
+ }
}
- }
- break;
+ break;
- case 'ACTION':
- $action = $alarm['action'] = strtoupper($value);
- break;
+ case 'ACTION':
+ $action = $alarm['action'] = strtoupper($value);
+ break;
- case 'SUMMARY':
- case 'DESCRIPTION':
- case 'DURATION':
- $alarm[strtolower($prop->name)] = $value;
- break;
+ case 'SUMMARY':
+ case 'DESCRIPTION':
+ case 'DURATION':
+ $alarm[strtolower($prop->name)] = $value;
+ break;
- case 'REPEAT':
- $alarm['repeat'] = (int) $value;
- break;
+ case 'REPEAT':
+ $alarm['repeat'] = (int) $value;
+ break;
- case 'ATTENDEE':
- $alarm['attendees'][] = preg_replace('!^mailto:!i', '', $value);
- break;
+ case 'ATTENDEE':
+ $alarm['attendees'][] = preg_replace('!^mailto:!i', '', $value);
+ break;
}
}
if ($action != 'NONE') {
if (!empty($alarm['trigger'])) {
$this->valarms[] = $alarm;
}
}
}
}
/**
* Create string representation of the DAV object (iCalendar)
*
* @return string
*/
public function __toString()
{
// TODO: This will be needed when we want to create/update objects
return '';
}
}
diff --git a/src/app/Console/Commands/User/ResyncCommand.php b/src/app/Console/Commands/User/ResyncCommand.php
index 0acd0fd6..d367bdbf 100644
--- a/src/app/Console/Commands/User/ResyncCommand.php
+++ b/src/app/Console/Commands/User/ResyncCommand.php
@@ -1,108 +1,109 @@
<?php
namespace App\Console\Commands\User;
use App\Console\Command;
use App\User;
class ResyncCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'user:resync {user?} {--deleted-only} {--dry-run}';
/**
* The console command description.
*
* @var string
*/
protected $description = "Re-Synchronize users with the imap/ldap backend(s)";
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$user = $this->argument('user');
$deleted_only = $this->option('deleted-only');
$dry_run = $this->option('dry-run');
$with_ldap = \config('app.with_ldap');
if (!empty($user)) {
if ($req_user = $this->getUser($user, true)) {
$users = [$req_user];
} else {
$this->error("User not found.");
return 1;
}
} else {
$users = User::withTrashed();
if ($deleted_only) {
$users->whereNotNull('deleted_at')
- ->where(function($query) {
- $query->where('status', '&', User::STATUS_IMAP_READY)->orWhere('status', '&', User::STATUS_LDAP_READY);
+ ->where(function ($query) {
+ $query->where('status', '&', User::STATUS_IMAP_READY)
+ ->orWhere('status', '&', User::STATUS_LDAP_READY);
});
}
$users = $users->orderBy('id')->cursor();
}
// TODO: Maybe we should also have account:resync, domain:resync, resource:resync and so on.
foreach ($users as $user) {
if ($user->trashed()) {
if (($with_ldap && $user->isLdapReady()) || $user->isImapReady()) {
if ($dry_run) {
$this->info("{$user->email}: will be pushed");
continue;
}
if ($user->isDeleted()) {
// Remove the DELETED flag so the DeleteJob can do the work
$user->timestamps = false;
$user->update(['status' => $user->status ^ User::STATUS_DELETED]);
}
// TODO: Do this not asyncronously as an option or when a signle user is requested?
\App\Jobs\User\DeleteJob::dispatch($user->id);
$this->info("{$user->email}: pushed");
} else {
// User properly deleted, no need to push.
// Here potentially we could connect to ldap/imap backend and check to be sure
// that the user is really deleted no matter what status it has in the database.
$this->info("{$user->email}: in-sync");
}
} else {
if (!$user->isActive() || ($with_ldap && !$user->isLdapReady()) || !$user->isImapReady()) {
if ($dry_run) {
$this->info("{$user->email}: will be pushed");
continue;
}
\App\Jobs\User\CreateJob::dispatch($user->id);
- } else if (!empty($req_user)) {
+ } elseif (!empty($req_user)) {
if ($dry_run) {
$this->info("{$user->email}: will be pushed");
continue;
}
// We push the update only if a specific user is requested,
// We don't want to flood the database/backend with an update of all users
\App\Jobs\User\UpdateJob::dispatch($user->id);
$this->info("{$user->email}: pushed");
} else {
$this->info("{$user->email}: in-sync");
}
}
}
}
}
diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php
index bc1ee188..17d6638d 100644
--- a/src/app/Http/Controllers/API/V4/PaymentsController.php
+++ b/src/app/Http/Controllers/API/V4/PaymentsController.php
@@ -1,519 +1,519 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use App\Payment;
use App\Providers\PaymentProvider;
use App\Tenant;
use App\Wallet;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class PaymentsController extends Controller
{
/**
* Get the auto-payment mandate info.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandate()
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$mandate = self::walletMandate($wallet);
return response()->json($mandate);
}
/**
* Create a new auto-payment mandate.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandateCreate(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
// Input validation
if ($errors = self::mandateValidate($request, $wallet)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$wallet->setSettings([
'mandate_amount' => $request->amount,
'mandate_balance' => $request->balance,
]);
$mandate = [
'currency' => $wallet->currency,
'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Auto-Payment Setup',
'methodId' => $request->methodId ?: PaymentProvider::METHOD_CREDITCARD,
];
// Normally the auto-payment setup operation is 0, if the balance is below the threshold
// we'll top-up the wallet with the configured auto-payment amount
if ($wallet->balance < intval($request->balance * 100)) {
$mandate['amount'] = intval($request->amount * 100);
self::addTax($wallet, $mandate);
}
$provider = PaymentProvider::factory($wallet);
$result = $provider->createMandate($wallet, $mandate);
$result['status'] = 'success';
return response()->json($result);
}
/**
* Revoke the auto-payment mandate.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandateDelete()
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$provider = PaymentProvider::factory($wallet);
$provider->deleteMandate($wallet);
$wallet->setSetting('mandate_disabled', null);
return response()->json([
'status' => 'success',
'message' => \trans('app.mandate-delete-success'),
]);
}
/**
* Update a new auto-payment mandate.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandateUpdate(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
// Input validation
if ($errors = self::mandateValidate($request, $wallet)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$wallet->setSettings([
'mandate_amount' => $request->amount,
'mandate_balance' => $request->balance,
// Re-enable the mandate to give it a chance to charge again
// after it has been disabled (e.g. because the mandate amount was too small)
'mandate_disabled' => null,
]);
// Trigger auto-payment if the balance is below the threshold
if ($wallet->balance < intval($request->balance * 100)) {
\App\Jobs\WalletCharge::dispatch($wallet);
}
$result = self::walletMandate($wallet);
$result['status'] = 'success';
$result['message'] = \trans('app.mandate-update-success');
return response()->json($result);
}
/**
* Validate an auto-payment mandate request.
*
* @param \Illuminate\Http\Request $request The API request.
* @param \App\Wallet $wallet The wallet
*
* @return array|null List of errors on error or Null on success
*/
protected static function mandateValidate(Request $request, Wallet $wallet)
{
$rules = [
'amount' => 'required|numeric',
'balance' => 'required|numeric|min:0',
];
// Check required fields
$v = Validator::make($request->all(), $rules);
// TODO: allow comma as a decimal point?
if ($v->fails()) {
return $v->errors()->toArray();
}
$amount = (int) ($request->amount * 100);
// Validate the minimum value
// It has to be at least minimum payment amount and must cover current debt
if (
$wallet->balance < 0
&& $wallet->balance <= Payment::MIN_AMOUNT * -1
&& $wallet->balance + $amount < 0
) {
return ['amount' => \trans('validation.minamountdebt')];
}
if ($amount < Payment::MIN_AMOUNT) {
$min = $wallet->money(Payment::MIN_AMOUNT);
return ['amount' => \trans('validation.minamount', ['amount' => $min])];
}
return null;
}
/**
* Create a new payment.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$rules = [
'amount' => 'required|numeric',
];
// Check required fields
$v = Validator::make($request->all(), $rules);
// TODO: allow comma as a decimal point?
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
$amount = (int) ($request->amount * 100);
// Validate the minimum value
if ($amount < Payment::MIN_AMOUNT) {
$min = $wallet->money(Payment::MIN_AMOUNT);
$errors = ['amount' => \trans('validation.minamount', ['amount' => $min])];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$currency = $request->currency;
$request = [
'type' => Payment::TYPE_ONEOFF,
'currency' => $currency,
'amount' => $amount,
'methodId' => $request->methodId ?: PaymentProvider::METHOD_CREDITCARD,
'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Payment',
];
self::addTax($wallet, $request);
$provider = PaymentProvider::factory($wallet, $currency);
$result = $provider->payment($wallet, $request);
$result['status'] = 'success';
return response()->json($result);
}
/**
* Delete a pending payment.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
// TODO currently unused
// public function cancel(Request $request)
// {
// $user = $this->guard()->user();
// // TODO: Wallet selection
// $wallet = $user->wallets()->first();
// $paymentId = $request->payment;
// $user_owns_payment = Payment::where('id', $paymentId)
// ->where('wallet_id', $wallet->id)
// ->exists();
// if (!$user_owns_payment) {
// return $this->errorResponse(404);
// }
// $provider = PaymentProvider::factory($wallet);
// if ($provider->cancel($wallet, $paymentId)) {
// $result = ['status' => 'success'];
// return response()->json($result);
// }
// return $this->errorResponse(404);
// }
/**
* Update payment status (and balance).
*
* @param string $provider Provider name
*
* @return \Illuminate\Http\Response The response
*/
public function webhook($provider)
{
$code = 200;
if ($provider = PaymentProvider::factory($provider)) {
$code = $provider->webhook();
}
return response($code < 400 ? 'Success' : 'Server error', $code);
}
/**
* Top up a wallet with a "recurring" payment.
*
* @param \App\Wallet $wallet The wallet to charge
*
* @return bool True if the payment has been initialized
*/
public static function topUpWallet(Wallet $wallet): bool
{
$settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']);
\Log::debug("Requested top-up for wallet {$wallet->id}");
if (!empty($settings['mandate_disabled'])) {
\Log::debug("Top-up for wallet {$wallet->id}: mandate disabled");
return false;
}
$min_balance = (int) (floatval($settings['mandate_balance']) * 100);
$amount = (int) (floatval($settings['mandate_amount']) * 100);
// The wallet balance is greater than the auto-payment threshold
if ($wallet->balance >= $min_balance) {
// Do nothing
return false;
}
$provider = PaymentProvider::factory($wallet);
$mandate = (array) $provider->getMandate($wallet);
if (empty($mandate['isValid'])) {
\Log::debug("Top-up for wallet {$wallet->id}: mandate invalid");
return false;
}
// The defined top-up amount is not enough
// Disable auto-payment and notify the user
if ($wallet->balance + $amount < 0) {
// Disable (not remove) the mandate
$wallet->setSetting('mandate_disabled', 1);
\App\Jobs\PaymentMandateDisabledEmail::dispatch($wallet);
return false;
}
$request = [
'type' => Payment::TYPE_RECURRING,
'currency' => $wallet->currency,
'amount' => $amount,
'methodId' => PaymentProvider::METHOD_CREDITCARD,
'description' => Tenant::getConfig($wallet->owner->tenant_id, 'app.name') . ' Recurring Payment',
];
self::addTax($wallet, $request);
$result = $provider->payment($wallet, $request);
return !empty($result);
}
/**
* Returns auto-payment mandate info for the specified wallet
*
* @param \App\Wallet $wallet A wallet object
*
* @return array A mandate metadata
*/
public static function walletMandate(Wallet $wallet): array
{
$provider = PaymentProvider::factory($wallet);
$settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']);
// Get the Mandate info
$mandate = (array) $provider->getMandate($wallet);
$mandate['amount'] = (int) (Payment::MIN_AMOUNT / 100);
$mandate['balance'] = 0;
$mandate['isDisabled'] = !empty($mandate['id']) && $settings['mandate_disabled'];
foreach (['amount', 'balance'] as $key) {
if (($value = $settings["mandate_{$key}"]) !== null) {
$mandate[$key] = $value;
}
}
return $mandate;
}
/**
* List supported payment methods.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function paymentMethods(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$methods = PaymentProvider::paymentMethods($wallet, $request->type);
\Log::debug("Provider methods" . var_export(json_encode($methods), true));
return response()->json($methods);
}
/**
* Check for pending payments.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function hasPayments(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$exists = Payment::where('wallet_id', $wallet->id)
->where('type', Payment::TYPE_ONEOFF)
->whereIn('status', [
Payment::STATUS_OPEN,
Payment::STATUS_PENDING,
Payment::STATUS_AUTHORIZED
])
->exists();
return response()->json([
'status' => 'success',
'hasPending' => $exists
]);
}
/**
* List pending payments.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function payments(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$pageSize = 10;
$page = intval(request()->input('page')) ?: 1;
$hasMore = false;
$result = Payment::where('wallet_id', $wallet->id)
->where('type', Payment::TYPE_ONEOFF)
->whereIn('status', [
Payment::STATUS_OPEN,
Payment::STATUS_PENDING,
Payment::STATUS_AUTHORIZED
])
->orderBy('created_at', 'desc')
->limit($pageSize + 1)
->offset($pageSize * ($page - 1))
->get();
if (count($result) > $pageSize) {
$result->pop();
$hasMore = true;
}
$result = $result->map(function ($item) use ($wallet) {
$provider = PaymentProvider::factory($item->provider);
$payment = $provider->getPayment($item->id);
$entry = [
'id' => $item->id,
'createdAt' => $item->created_at->format('Y-m-d H:i'),
'type' => $item->type,
'description' => $item->description,
'amount' => $item->amount,
'currency' => $wallet->currency,
// note: $item->currency/$item->currency_amount might be different
'status' => $item->status,
'isCancelable' => $payment['isCancelable'],
'checkoutUrl' => $payment['checkoutUrl']
];
return $entry;
});
return response()->json([
'status' => 'success',
'list' => $result,
'count' => count($result),
'hasMore' => $hasMore,
'page' => $page,
]);
}
/**
* Calculates tax for the payment, fills the request with additional properties
*/
protected static function addTax(Wallet $wallet, array &$request): void
{
$request['vat_rate_id'] = null;
$request['credit_amount'] = $request['amount'];
if ($rate = $wallet->vatRate()) {
$request['vat_rate_id'] = $rate->id;
switch (\config('app.vat.mode')) {
- case 1:
- // In this mode tax is added on top of the payment. The amount
- // to pay grows, but we keep wallet balance without tax.
- $request['amount'] = $request['amount'] + round($request['amount'] * $rate->rate / 100);
- break;
-
- default:
- // In this mode tax is "swallowed" by the vendor. The payment
- // amount does not change
- break;
+ case 1:
+ // In this mode tax is added on top of the payment. The amount
+ // to pay grows, but we keep wallet balance without tax.
+ $request['amount'] = $request['amount'] + round($request['amount'] * $rate->rate / 100);
+ break;
+
+ default:
+ // In this mode tax is "swallowed" by the vendor. The payment
+ // amount does not change
+ break;
}
}
}
}
diff --git a/src/app/Observers/WalletObserver.php b/src/app/Observers/WalletObserver.php
index 201716aa..5327d154 100644
--- a/src/app/Observers/WalletObserver.php
+++ b/src/app/Observers/WalletObserver.php
@@ -1,122 +1,124 @@
<?php
namespace App\Observers;
use App\User;
use App\Wallet;
/**
* This is an observer for the Wallet model definition.
*/
class WalletObserver
{
/**
* Ensure the wallet ID is a custom ID (uuid).
*
* @param Wallet $wallet
*
* @return void
*/
public function creating(Wallet $wallet)
{
$wallet->currency = \config('app.currency');
}
/**
* Handle the wallet "deleting" event.
*
* Ensures that a wallet with a non-zero balance can not be deleted.
*
* Ensures that the wallet being deleted is not the last wallet for the user.
*
* Ensures that no entitlements are being billed to the wallet currently.
*
* @param Wallet $wallet The wallet being deleted.
*
* @return bool
*/
public function deleting(Wallet $wallet): bool
{
// can't delete a wallet that has any balance on it (positive and negative).
if ($wallet->balance != 0.00) {
return false;
}
if (!$wallet->owner) {
throw new \Exception("Wallet: " . var_export($wallet, true));
}
// can't remove the last wallet for the owner.
if ($wallet->owner->wallets()->count() <= 1) {
return false;
}
// can't remove a wallet that has billable entitlements attached.
if ($wallet->entitlements()->count() > 0) {
return false;
}
/*
// can't remove a wallet that has payments attached.
if ($wallet->payments()->count() > 0) {
return false;
}
*/
return true;
}
/**
* Handle the wallet "updated" event.
*
* @param \App\Wallet $wallet The wallet.
*
* @return void
*/
public function updated(Wallet $wallet)
{
$negative_since = $wallet->getSetting('balance_negative_since');
if ($wallet->balance < 0) {
if (!$negative_since) {
$now = \Carbon\Carbon::now()->toDateTimeString();
$wallet->setSetting('balance_negative_since', $now);
}
} elseif ($negative_since) {
$wallet->setSettings([
'balance_negative_since' => null,
'balance_warning_initial' => null,
'balance_warning_reminder' => null,
'balance_warning_suspended' => null,
'balance_warning_before_delete' => null,
]);
// FIXME: Since we use account degradation, should we leave suspended state untouched?
// Un-suspend and un-degrade the account owner
if ($wallet->owner) {
$wallet->owner->unsuspend();
$wallet->owner->undegrade();
}
// Un-suspend domains/users
foreach ($wallet->entitlements as $entitlement) {
if (
method_exists($entitlement->entitleable_type, 'unsuspend')
&& !empty($entitlement->entitleable)
) {
$entitlement->entitleable->unsuspend();
}
}
}
// Remove RESTRICTED flag from the wallet owner and all users in the wallet
if ($wallet->balance > $wallet->getOriginal('balance') && $wallet->owner && $wallet->owner->isRestricted()) {
$wallet->owner->unrestrict();
- User::whereIn('id', $wallet->entitlements()->select('entitleable_id')->where('entitleable_type', User::class))
- ->each(function ($user) {
- $user->unrestrict();
- });
+ User::whereIn(
+ 'id',
+ $wallet->entitlements()->select('entitleable_id')->where('entitleable_type', User::class)
+ )->each(function ($user) {
+ $user->unrestrict();
+ });
}
}
}
diff --git a/src/database/migrations/2023_02_17_100000_vat_rates_table.php b/src/database/migrations/2023_02_17_100000_vat_rates_table.php
index 86cc3b82..2c034ba2 100644
--- a/src/database/migrations/2023_02_17_100000_vat_rates_table.php
+++ b/src/database/migrations/2023_02_17_100000_vat_rates_table.php
@@ -1,88 +1,88 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('vat_rates', function (Blueprint $table) {
$table->string('id', 36)->primary();
$table->string('country', 2);
$table->timestamp('start')->useCurrent();
$table->double('rate', 5, 2);
$table->unique(['country', 'start']);
});
Schema::table(
'payments',
function (Blueprint $table) {
$table->string('vat_rate_id', 36)->nullable();
$table->integer('credit_amount')->nullable(); // temporarily allow null
$table->foreign('vat_rate_id')->references('id')->on('vat_rates')->onUpdate('cascade');
}
);
DB::table('payments')->update(['credit_amount' => DB::raw("`amount`")]);
Schema::table(
'payments',
function (Blueprint $table) {
$table->integer('credit_amount')->nullable(false)->change(); // remove nullable
}
);
// Migrate old tax rates (and existing payments)
if (($countries = \env('VAT_COUNTRIES')) && ($rate = \env('VAT_RATE'))) {
$countries = explode(',', strtoupper(trim($countries)));
foreach ($countries as $country) {
$vatRate = \App\VatRate::create([
'start' => new DateTime('2010-01-01 00:00:00'),
'rate' => $rate,
'country' => $country,
]);
DB::table('payments')->whereIn('wallet_id', function ($query) use ($country) {
$query->select('id')
->from('wallets')
->whereIn('user_id', function ($query) use ($country) {
$query->select('user_id')
->from('user_settings')
->where('key', 'country')
->where('value', $country);
- });
+ });
})
->update(['vat_rate_id' => $vatRate->id]);
}
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table(
'payments',
function (Blueprint $table) {
$table->dropColumn('vat_rate_id');
$table->dropColumn('credit_amount');
}
);
Schema::dropIfExists('vat_rates');
}
};
diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php
index 0bd0b621..39f09fda 100644
--- a/src/tests/Feature/UserTest.php
+++ b/src/tests/Feature/UserTest.php
@@ -1,1418 +1,1420 @@
<?php
namespace Tests\Feature;
use App\Domain;
use App\Group;
use App\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class UserTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('user-test@' . \config('app.domain'));
$this->deleteTestUser('UserAccountA@UserAccount.com');
$this->deleteTestUser('UserAccountB@UserAccount.com');
$this->deleteTestUser('UserAccountC@UserAccount.com');
$this->deleteTestGroup('test-group@UserAccount.com');
$this->deleteTestResource('test-resource@UserAccount.com');
$this->deleteTestSharedFolder('test-folder@UserAccount.com');
$this->deleteTestDomain('UserAccount.com');
$this->deleteTestDomain('UserAccountAdd.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
\App\TenantSetting::truncate();
$this->deleteTestUser('user-test@' . \config('app.domain'));
$this->deleteTestUser('UserAccountA@UserAccount.com');
$this->deleteTestUser('UserAccountB@UserAccount.com');
$this->deleteTestUser('UserAccountC@UserAccount.com');
$this->deleteTestGroup('test-group@UserAccount.com');
$this->deleteTestResource('test-resource@UserAccount.com');
$this->deleteTestSharedFolder('test-folder@UserAccount.com');
$this->deleteTestDomain('UserAccount.com');
$this->deleteTestDomain('UserAccountAdd.com');
parent::tearDown();
}
/**
* Tests for User::assignPackage()
*/
public function testAssignPackage(): void
{
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$user->assignPackage($package);
$sku = \App\Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$entitlement = \App\Entitlement::where('wallet_id', $wallet->id)
->where('sku_id', $sku->id)->first();
$this->assertNotNull($entitlement);
$this->assertSame($sku->id, $entitlement->sku->id);
$this->assertSame($wallet->id, $entitlement->wallet->id);
$this->assertEquals($user->id, $entitlement->entitleable->id);
$this->assertTrue($entitlement->entitleable instanceof \App\User);
$this->assertCount(7, $user->entitlements()->get());
}
/**
* Tests for User::assignPlan()
*/
public function testAssignPlan(): void
{
$this->markTestIncomplete();
}
/**
* Tests for User::assignSku()
*/
public function testAssignSku(): void
{
$this->markTestIncomplete();
}
/**
* Verify a wallet assigned a controller is among the accounts of the assignee.
*/
public function testAccounts(): void
{
$userA = $this->getTestUser('UserAccountA@UserAccount.com');
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$this->assertTrue($userA->wallets()->count() == 1);
$userA->wallets()->each(
function ($wallet) use ($userB) {
$wallet->addController($userB);
}
);
$this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id);
}
/**
* Test User::canDelete() method
*/
public function testCanDelete(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$domain = $this->getTestDomain('kolab.org');
// Admin
$this->assertTrue($admin->canDelete($admin));
$this->assertFalse($admin->canDelete($john));
$this->assertFalse($admin->canDelete($jack));
$this->assertFalse($admin->canDelete($reseller1));
$this->assertFalse($admin->canDelete($domain));
$this->assertFalse($admin->canDelete($domain->wallet()));
// Reseller - kolabnow
$this->assertFalse($reseller1->canDelete($john));
$this->assertFalse($reseller1->canDelete($jack));
$this->assertTrue($reseller1->canDelete($reseller1));
$this->assertFalse($reseller1->canDelete($domain));
$this->assertFalse($reseller1->canDelete($domain->wallet()));
$this->assertFalse($reseller1->canDelete($admin));
// Normal user - account owner
$this->assertTrue($john->canDelete($john));
$this->assertTrue($john->canDelete($ned));
$this->assertTrue($john->canDelete($jack));
$this->assertTrue($john->canDelete($domain));
$this->assertFalse($john->canDelete($domain->wallet()));
$this->assertFalse($john->canDelete($reseller1));
$this->assertFalse($john->canDelete($admin));
// Normal user - a non-owner and non-controller
$this->assertFalse($jack->canDelete($jack));
$this->assertFalse($jack->canDelete($john));
$this->assertFalse($jack->canDelete($domain));
$this->assertFalse($jack->canDelete($domain->wallet()));
$this->assertFalse($jack->canDelete($reseller1));
$this->assertFalse($jack->canDelete($admin));
// Normal user - John's wallet controller
$this->assertTrue($ned->canDelete($ned));
$this->assertTrue($ned->canDelete($john));
$this->assertTrue($ned->canDelete($jack));
$this->assertTrue($ned->canDelete($domain));
$this->assertFalse($ned->canDelete($domain->wallet()));
$this->assertFalse($ned->canDelete($reseller1));
$this->assertFalse($ned->canDelete($admin));
}
/**
* Test User::canRead() method
*/
public function testCanRead(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$domain = $this->getTestDomain('kolab.org');
// Admin
$this->assertTrue($admin->canRead($admin));
$this->assertTrue($admin->canRead($john));
$this->assertTrue($admin->canRead($jack));
$this->assertTrue($admin->canRead($reseller1));
$this->assertTrue($admin->canRead($reseller2));
$this->assertTrue($admin->canRead($domain));
$this->assertTrue($admin->canRead($domain->wallet()));
// Reseller - kolabnow
$this->assertTrue($reseller1->canRead($john));
$this->assertTrue($reseller1->canRead($jack));
$this->assertTrue($reseller1->canRead($reseller1));
$this->assertTrue($reseller1->canRead($domain));
$this->assertTrue($reseller1->canRead($domain->wallet()));
$this->assertFalse($reseller1->canRead($reseller2));
$this->assertFalse($reseller1->canRead($admin));
// Reseller - different tenant
$this->assertTrue($reseller2->canRead($reseller2));
$this->assertFalse($reseller2->canRead($john));
$this->assertFalse($reseller2->canRead($jack));
$this->assertFalse($reseller2->canRead($reseller1));
$this->assertFalse($reseller2->canRead($domain));
$this->assertFalse($reseller2->canRead($domain->wallet()));
$this->assertFalse($reseller2->canRead($admin));
// Normal user - account owner
$this->assertTrue($john->canRead($john));
$this->assertTrue($john->canRead($ned));
$this->assertTrue($john->canRead($jack));
$this->assertTrue($john->canRead($domain));
$this->assertTrue($john->canRead($domain->wallet()));
$this->assertFalse($john->canRead($reseller1));
$this->assertFalse($john->canRead($reseller2));
$this->assertFalse($john->canRead($admin));
// Normal user - a non-owner and non-controller
$this->assertTrue($jack->canRead($jack));
$this->assertFalse($jack->canRead($john));
$this->assertFalse($jack->canRead($domain));
$this->assertFalse($jack->canRead($domain->wallet()));
$this->assertFalse($jack->canRead($reseller1));
$this->assertFalse($jack->canRead($reseller2));
$this->assertFalse($jack->canRead($admin));
// Normal user - John's wallet controller
$this->assertTrue($ned->canRead($ned));
$this->assertTrue($ned->canRead($john));
$this->assertTrue($ned->canRead($jack));
$this->assertTrue($ned->canRead($domain));
$this->assertTrue($ned->canRead($domain->wallet()));
$this->assertFalse($ned->canRead($reseller1));
$this->assertFalse($ned->canRead($reseller2));
$this->assertFalse($ned->canRead($admin));
}
/**
* Test User::canUpdate() method
*/
public function testCanUpdate(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$domain = $this->getTestDomain('kolab.org');
// Admin
$this->assertTrue($admin->canUpdate($admin));
$this->assertTrue($admin->canUpdate($john));
$this->assertTrue($admin->canUpdate($jack));
$this->assertTrue($admin->canUpdate($reseller1));
$this->assertTrue($admin->canUpdate($reseller2));
$this->assertTrue($admin->canUpdate($domain));
$this->assertTrue($admin->canUpdate($domain->wallet()));
// Reseller - kolabnow
$this->assertTrue($reseller1->canUpdate($john));
$this->assertTrue($reseller1->canUpdate($jack));
$this->assertTrue($reseller1->canUpdate($reseller1));
$this->assertTrue($reseller1->canUpdate($domain));
$this->assertTrue($reseller1->canUpdate($domain->wallet()));
$this->assertFalse($reseller1->canUpdate($reseller2));
$this->assertFalse($reseller1->canUpdate($admin));
// Reseller - different tenant
$this->assertTrue($reseller2->canUpdate($reseller2));
$this->assertFalse($reseller2->canUpdate($john));
$this->assertFalse($reseller2->canUpdate($jack));
$this->assertFalse($reseller2->canUpdate($reseller1));
$this->assertFalse($reseller2->canUpdate($domain));
$this->assertFalse($reseller2->canUpdate($domain->wallet()));
$this->assertFalse($reseller2->canUpdate($admin));
// Normal user - account owner
$this->assertTrue($john->canUpdate($john));
$this->assertTrue($john->canUpdate($ned));
$this->assertTrue($john->canUpdate($jack));
$this->assertTrue($john->canUpdate($domain));
$this->assertFalse($john->canUpdate($domain->wallet()));
$this->assertFalse($john->canUpdate($reseller1));
$this->assertFalse($john->canUpdate($reseller2));
$this->assertFalse($john->canUpdate($admin));
// Normal user - a non-owner and non-controller
$this->assertTrue($jack->canUpdate($jack));
$this->assertFalse($jack->canUpdate($john));
$this->assertFalse($jack->canUpdate($domain));
$this->assertFalse($jack->canUpdate($domain->wallet()));
$this->assertFalse($jack->canUpdate($reseller1));
$this->assertFalse($jack->canUpdate($reseller2));
$this->assertFalse($jack->canUpdate($admin));
// Normal user - John's wallet controller
$this->assertTrue($ned->canUpdate($ned));
$this->assertTrue($ned->canUpdate($john));
$this->assertTrue($ned->canUpdate($jack));
$this->assertTrue($ned->canUpdate($domain));
$this->assertFalse($ned->canUpdate($domain->wallet()));
$this->assertFalse($ned->canUpdate($reseller1));
$this->assertFalse($ned->canUpdate($reseller2));
$this->assertFalse($ned->canUpdate($admin));
}
/**
* Test user created/creating/updated observers
*/
public function testCreateAndUpdate(): void
{
Queue::fake();
$domain = \config('app.domain');
\App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', 0);
$user = User::create([
'email' => 'USER-test@' . \strtoupper($domain),
'password' => 'test',
]);
$result = User::where('email', "user-test@$domain")->first();
$this->assertSame("user-test@$domain", $result->email);
$this->assertSame($user->id, $result->id);
$this->assertSame(User::STATUS_NEW, $result->status);
$this->assertSame(0, $user->passwords()->count());
Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1);
Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 0);
Queue::assertPushed(
\App\Jobs\User\CreateJob::class,
function ($job) use ($user) {
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
$userId = TestCase::getObjectProperty($job, 'userId');
return $userEmail === $user->email
&& $userId === $user->id;
}
);
// Test invoking KeyCreateJob
$this->deleteTestUser("user-test@$domain");
\App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', 1);
$user = User::create(['email' => "user-test@$domain", 'password' => 'test']);
Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1);
Queue::assertPushed(
\App\Jobs\PGP\KeyCreateJob::class,
function ($job) use ($user) {
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
$userId = TestCase::getObjectProperty($job, 'userId');
return $userEmail === $user->email
&& $userId === $user->id;
}
);
// Update the user, test the password change
$user->setSetting('password_expiration_warning', '2020-10-10 10:10:10');
$oldPassword = $user->password;
$user->password = 'test123';
$user->save();
$this->assertNotEquals($oldPassword, $user->password);
$this->assertSame(0, $user->passwords()->count());
$this->assertNull($user->getSetting('password_expiration_warning'));
$this->assertMatchesRegularExpression(
'/^' . now()->format('Y-m-d') . ' [0-9]{2}:[0-9]{2}:[0-9]{2}$/',
$user->getSetting('password_update')
);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
Queue::assertPushed(
\App\Jobs\User\UpdateJob::class,
function ($job) use ($user) {
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
$userId = TestCase::getObjectProperty($job, 'userId');
return $userEmail === $user->email
&& $userId === $user->id;
}
);
// Update the user, test the password history
$user->setSetting('password_policy', 'last:3');
$oldPassword = $user->password;
$user->password = 'test1234';
$user->save();
$this->assertSame(1, $user->passwords()->count());
$this->assertSame($oldPassword, $user->passwords()->first()->password);
$user->password = 'test12345';
$user->save();
$oldPassword = $user->password;
$user->password = 'test123456';
$user->save();
$this->assertSame(2, $user->passwords()->count());
$this->assertSame($oldPassword, $user->passwords()->latest()->first()->password);
}
/**
* Tests for User::domains()
*/
public function testDomains(): void
{
$user = $this->getTestUser('john@kolab.org');
$domain = $this->getTestDomain('useraccount.com', [
'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE,
'type' => Domain::TYPE_PUBLIC,
]);
$domains = $user->domains()->pluck('namespace')->all();
$this->assertContains($domain->namespace, $domains);
$this->assertContains('kolab.org', $domains);
// Jack is not the wallet controller, so for him the list should not
// include John's domains, kolab.org specifically
$user = $this->getTestUser('jack@kolab.org');
$domains = $user->domains()->pluck('namespace')->all();
$this->assertContains($domain->namespace, $domains);
$this->assertNotContains('kolab.org', $domains);
// Public domains of other tenants should not be returned
$tenant = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->first();
$domain->tenant_id = $tenant->id;
$domain->save();
$domains = $user->domains()->pluck('namespace')->all();
$this->assertNotContains($domain->namespace, $domains);
}
/**
* Test User::getConfig() and setConfig() methods
*/
public function testConfigTrait(): void
{
$user = $this->getTestUser('UserAccountA@UserAccount.com');
$user->setSetting('greylist_enabled', null);
$user->setSetting('guam_enabled', null);
$user->setSetting('password_policy', null);
$user->setSetting('max_password_age', null);
$user->setSetting('limit_geo', null);
// greylist_enabled
$this->assertSame(true, $user->getConfig()['greylist_enabled']);
$result = $user->setConfig(['greylist_enabled' => false, 'unknown' => false]);
$this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result);
$this->assertSame(false, $user->getConfig()['greylist_enabled']);
$this->assertSame('false', $user->getSetting('greylist_enabled'));
$result = $user->setConfig(['greylist_enabled' => true]);
$this->assertSame([], $result);
$this->assertSame(true, $user->getConfig()['greylist_enabled']);
$this->assertSame('true', $user->getSetting('greylist_enabled'));
// guam_enabled
$this->assertSame(false, $user->getConfig()['guam_enabled']);
$result = $user->setConfig(['guam_enabled' => false]);
$this->assertSame([], $result);
$this->assertSame(false, $user->getConfig()['guam_enabled']);
$this->assertSame(null, $user->getSetting('guam_enabled'));
$result = $user->setConfig(['guam_enabled' => true]);
$this->assertSame([], $result);
$this->assertSame(true, $user->getConfig()['guam_enabled']);
$this->assertSame('true', $user->getSetting('guam_enabled'));
// max_apssword_age
$this->assertSame(null, $user->getConfig()['max_password_age']);
$result = $user->setConfig(['max_password_age' => -1]);
$this->assertSame([], $result);
$this->assertSame(null, $user->getConfig()['max_password_age']);
$this->assertSame(null, $user->getSetting('max_password_age'));
$result = $user->setConfig(['max_password_age' => 12]);
$this->assertSame([], $result);
$this->assertSame('12', $user->getConfig()['max_password_age']);
$this->assertSame('12', $user->getSetting('max_password_age'));
// password_policy
$result = $user->setConfig(['password_policy' => true]);
$this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
$this->assertSame(null, $user->getConfig()['password_policy']);
$this->assertSame(null, $user->getSetting('password_policy'));
$result = $user->setConfig(['password_policy' => 'min:-1']);
$this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
$result = $user->setConfig(['password_policy' => 'min:-1']);
$this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
$result = $user->setConfig(['password_policy' => 'min:10,unknown']);
$this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
\config(['app.password_policy' => 'min:5,max:100']);
$result = $user->setConfig(['password_policy' => 'min:4,max:255']);
$this->assertSame(['password_policy' => "Minimum password length cannot be less than 5."], $result);
\config(['app.password_policy' => 'min:5,max:100']);
$result = $user->setConfig(['password_policy' => 'min:10,max:255']);
$this->assertSame(['password_policy' => "Maximum password length cannot be more than 100."], $result);
\config(['app.password_policy' => 'min:5,max:255']);
$result = $user->setConfig(['password_policy' => 'min:10,max:255']);
$this->assertSame([], $result);
$this->assertSame('min:10,max:255', $user->getConfig()['password_policy']);
$this->assertSame('min:10,max:255', $user->getSetting('password_policy'));
// limit_geo
$this->assertSame([], $user->getConfig()['limit_geo']);
$result = $user->setConfig(['limit_geo' => '']);
$err = "Specified configuration is invalid. Expected a list of two-letter country codes.";
$this->assertSame(['limit_geo' => $err], $result);
$this->assertSame(null, $user->getSetting('limit_geo'));
$result = $user->setConfig(['limit_geo' => ['usa']]);
$this->assertSame(['limit_geo' => $err], $result);
$this->assertSame(null, $user->getSetting('limit_geo'));
$result = $user->setConfig(['limit_geo' => []]);
$this->assertSame([], $result);
$this->assertSame(null, $user->getSetting('limit_geo'));
$result = $user->setConfig(['limit_geo' => ['US', 'ru']]);
$this->assertSame([], $result);
$this->assertSame(['US', 'RU'], $user->getConfig()['limit_geo']);
$this->assertSame('["US","RU"]', $user->getSetting('limit_geo'));
}
/**
* Test user account degradation and un-degradation
*/
public function testDegradeAndUndegrade(): void
{
Queue::fake();
// Test an account with users, domain
$userA = $this->getTestUser('UserAccountA@UserAccount.com');
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$domain = $this->getTestDomain('UserAccount.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$userA->assignPackage($package_kolab);
$domain->assignPackage($package_domain, $userA);
$userA->assignPackage($package_kolab, $userB);
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
$entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id);
$yesterday = Carbon::now()->subDays(1);
$this->backdateEntitlements($entitlementsA->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1));
$this->backdateEntitlements($entitlementsB->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1));
$wallet = $userA->wallets->first();
$this->assertSame(7, $entitlementsA->count());
$this->assertSame(7, $entitlementsB->count());
$this->assertSame(7, $entitlementsA->whereDate('updated_at', $yesterday->toDateString())->count());
$this->assertSame(7, $entitlementsB->whereDate('updated_at', $yesterday->toDateString())->count());
$this->assertSame(0, $wallet->balance);
Queue::fake(); // reset queue state
// Degrade the account/wallet owner
$userA->degrade();
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
$this->assertTrue($userA->fresh()->isDegraded());
$this->assertTrue($userA->fresh()->isDegraded(true));
$this->assertFalse($userB->fresh()->isDegraded());
$this->assertTrue($userB->fresh()->isDegraded(true));
$balance = $wallet->fresh()->balance;
$this->assertTrue($balance <= -64);
$this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count());
$this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count());
// Expect one update job for every user
// @phpstan-ignore-next-line
$userIds = Queue::pushed(\App\Jobs\User\UpdateJob::class)->map(function ($job) {
return TestCase::getObjectProperty($job, 'userId');
})->all();
$this->assertSame([$userA->id, $userB->id], $userIds);
// Un-Degrade the account/wallet owner
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
$yesterday = Carbon::now()->subDays(1);
$this->backdateEntitlements($entitlementsA->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1));
$this->backdateEntitlements($entitlementsB->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1));
Queue::fake(); // reset queue state
$userA->undegrade();
$this->assertFalse($userA->fresh()->isDegraded());
$this->assertFalse($userA->fresh()->isDegraded(true));
$this->assertFalse($userB->fresh()->isDegraded());
$this->assertFalse($userB->fresh()->isDegraded(true));
// Expect no balance change, degraded account entitlements are free
$this->assertSame($balance, $wallet->fresh()->balance);
$this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count());
$this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count());
// Expect one update job for every user
// @phpstan-ignore-next-line
$userIds = Queue::pushed(\App\Jobs\User\UpdateJob::class)->map(function ($job) {
return TestCase::getObjectProperty($job, 'userId');
})->all();
$this->assertSame([$userA->id, $userB->id], $userIds);
}
/**
* Test user deletion
*/
public function testDelete(): void
{
Queue::fake();
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$user->assignPackage($package);
$id = $user->id;
$this->assertCount(7, $user->entitlements()->get());
$user->delete();
$this->assertCount(0, $user->entitlements()->get());
$this->assertTrue($user->fresh()->trashed());
$this->assertFalse($user->fresh()->isDeleted());
// Delete the user for real
$job = new \App\Jobs\User\DeleteJob($id);
$job->handle();
$this->assertTrue(User::withTrashed()->where('id', $id)->first()->isDeleted());
$user->forceDelete();
$this->assertCount(0, User::withTrashed()->where('id', $id)->get());
// Test an account with users, domain, and group, and resource
$userA = $this->getTestUser('UserAccountA@UserAccount.com');
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$userC = $this->getTestUser('UserAccountC@UserAccount.com');
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$domain = $this->getTestDomain('UserAccount.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$userA->assignPackage($package_kolab);
$domain->assignPackage($package_domain, $userA);
$userA->assignPackage($package_kolab, $userB);
$userA->assignPackage($package_kolab, $userC);
$group = $this->getTestGroup('test-group@UserAccount.com');
$group->assignToWallet($userA->wallets->first());
$resource = $this->getTestResource('test-resource@UserAccount.com', ['name' => 'test']);
$resource->assignToWallet($userA->wallets->first());
$folder = $this->getTestSharedFolder('test-folder@UserAccount.com', ['name' => 'test']);
$folder->assignToWallet($userA->wallets->first());
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
$entitlementsC = \App\Entitlement::where('entitleable_id', $userC->id);
$entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id);
$entitlementsGroup = \App\Entitlement::where('entitleable_id', $group->id);
$entitlementsResource = \App\Entitlement::where('entitleable_id', $resource->id);
$entitlementsFolder = \App\Entitlement::where('entitleable_id', $folder->id);
$this->assertSame(7, $entitlementsA->count());
$this->assertSame(7, $entitlementsB->count());
$this->assertSame(7, $entitlementsC->count());
$this->assertSame(1, $entitlementsDomain->count());
$this->assertSame(1, $entitlementsGroup->count());
$this->assertSame(1, $entitlementsResource->count());
$this->assertSame(1, $entitlementsFolder->count());
// Delete non-controller user
$userC->delete();
$this->assertTrue($userC->fresh()->trashed());
$this->assertFalse($userC->fresh()->isDeleted());
$this->assertSame(0, $entitlementsC->count());
// Delete the controller (and expect "sub"-users to be deleted too)
$userA->delete();
$this->assertSame(0, $entitlementsA->count());
$this->assertSame(0, $entitlementsB->count());
$this->assertSame(0, $entitlementsDomain->count());
$this->assertSame(0, $entitlementsGroup->count());
$this->assertSame(0, $entitlementsResource->count());
$this->assertSame(0, $entitlementsFolder->count());
$this->assertSame(7, $entitlementsA->withTrashed()->count());
$this->assertSame(7, $entitlementsB->withTrashed()->count());
$this->assertSame(7, $entitlementsC->withTrashed()->count());
$this->assertSame(1, $entitlementsDomain->withTrashed()->count());
$this->assertSame(1, $entitlementsGroup->withTrashed()->count());
$this->assertSame(1, $entitlementsResource->withTrashed()->count());
$this->assertSame(1, $entitlementsFolder->withTrashed()->count());
$this->assertTrue($userA->fresh()->trashed());
$this->assertTrue($userB->fresh()->trashed());
$this->assertTrue($domain->fresh()->trashed());
$this->assertTrue($group->fresh()->trashed());
$this->assertTrue($resource->fresh()->trashed());
$this->assertTrue($folder->fresh()->trashed());
$this->assertFalse($userA->isDeleted());
$this->assertFalse($userB->isDeleted());
$this->assertFalse($domain->isDeleted());
$this->assertFalse($group->isDeleted());
$this->assertFalse($resource->isDeleted());
$this->assertFalse($folder->isDeleted());
$userA->forceDelete();
$all_entitlements = \App\Entitlement::where('wallet_id', $userA->wallets->first()->id);
$transactions = \App\Transaction::where('object_id', $userA->wallets->first()->id);
$this->assertSame(0, $all_entitlements->withTrashed()->count());
$this->assertSame(0, $transactions->count());
$this->assertCount(0, User::withTrashed()->where('id', $userA->id)->get());
$this->assertCount(0, User::withTrashed()->where('id', $userB->id)->get());
$this->assertCount(0, User::withTrashed()->where('id', $userC->id)->get());
$this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get());
$this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get());
$this->assertCount(0, \App\Resource::withTrashed()->where('id', $resource->id)->get());
$this->assertCount(0, \App\SharedFolder::withTrashed()->where('id', $folder->id)->get());
}
/**
* Test user deletion vs. group membership
*/
public function testDeleteAndGroups(): void
{
Queue::fake();
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$userA = $this->getTestUser('UserAccountA@UserAccount.com');
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$userA->assignPackage($package_kolab, $userB);
$group = $this->getTestGroup('test-group@UserAccount.com');
$group->members = ['test@gmail.com', $userB->email];
$group->assignToWallet($userA->wallets->first());
$group->save();
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1);
$userGroups = $userA->groups()->get();
$this->assertSame(1, $userGroups->count());
$this->assertSame($group->id, $userGroups->first()->id);
$userB->delete();
$this->assertSame(['test@gmail.com'], $group->fresh()->members);
// Twice, one for save() and one for delete() above
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2);
}
/**
* Test handling negative balance on user deletion
*/
public function testDeleteWithNegativeBalance(): void
{
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->balance = -1000;
$wallet->save();
$reseller_wallet = $user->tenant->wallet();
$reseller_wallet->balance = 0;
$reseller_wallet->save();
\App\Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete();
$user->delete();
$reseller_transactions = \App\Transaction::where('object_id', $reseller_wallet->id)
->where('object_type', \App\Wallet::class)->get();
$this->assertSame(-1000, $reseller_wallet->fresh()->balance);
$this->assertCount(1, $reseller_transactions);
$trans = $reseller_transactions[0];
$this->assertSame("Deleted user {$user->email}", $trans->description);
$this->assertSame(-1000, $trans->amount);
$this->assertSame(\App\Transaction::WALLET_DEBIT, $trans->type);
}
/**
* Test handling positive balance on user deletion
*/
public function testDeleteWithPositiveBalance(): void
{
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->balance = 1000;
$wallet->save();
$reseller_wallet = $user->tenant->wallet();
$reseller_wallet->balance = 0;
$reseller_wallet->save();
$user->delete();
$this->assertSame(0, $reseller_wallet->fresh()->balance);
}
/**
* Test user deletion with PGP/WOAT enabled
*/
public function testDeleteWithPGP(): void
{
Queue::fake();
// Test with PGP disabled
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$user->tenant->setSetting('pgp.enable', 0);
$user->delete();
Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 0);
// Test with PGP enabled
$this->deleteTestUser('user-test@' . \config('app.domain'));
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$user->tenant->setSetting('pgp.enable', 1);
$user->delete();
$user->tenant->setSetting('pgp.enable', 0);
Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1);
Queue::assertPushed(
\App\Jobs\PGP\KeyDeleteJob::class,
function ($job) use ($user) {
$userId = TestCase::getObjectProperty($job, 'userId');
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
return $userId == $user->id && $userEmail === $user->email;
}
);
}
/**
* Test user deletion vs. rooms
*/
public function testDeleteWithRooms(): void
{
$this->markTestIncomplete();
}
/**
* Tests for User::aliasExists()
*/
public function testAliasExists(): void
{
$this->assertTrue(User::aliasExists('jack.daniels@kolab.org'));
$this->assertFalse(User::aliasExists('j.daniels@kolab.org'));
$this->assertFalse(User::aliasExists('john@kolab.org'));
}
/**
* Tests for User::emailExists()
*/
public function testEmailExists(): void
{
$this->assertFalse(User::emailExists('jack.daniels@kolab.org'));
$this->assertFalse(User::emailExists('j.daniels@kolab.org'));
$this->assertTrue(User::emailExists('john@kolab.org'));
$user = User::emailExists('john@kolab.org', true);
$this->assertSame('john@kolab.org', $user->email);
}
/**
* Tests for User::findByEmail()
*/
public function testFindByEmail(): void
{
$user = $this->getTestUser('john@kolab.org');
$result = User::findByEmail('john');
$this->assertNull($result);
$result = User::findByEmail('non-existing@email.com');
$this->assertNull($result);
$result = User::findByEmail('john@kolab.org');
$this->assertInstanceOf(User::class, $result);
$this->assertSame($user->id, $result->id);
// Use an alias
$result = User::findByEmail('john.doe@kolab.org');
$this->assertInstanceOf(User::class, $result);
$this->assertSame($user->id, $result->id);
Queue::fake();
// A case where two users have the same alias
$ned = $this->getTestUser('ned@kolab.org');
$ned->setAliases(['joe.monster@kolab.org']);
$result = User::findByEmail('joe.monster@kolab.org');
$this->assertNull($result);
$ned->setAliases([]);
// TODO: searching by external email (setting)
$this->markTestIncomplete();
}
/**
* Test User::hasSku() and countEntitlementsBySku() methods
*/
public function testHasSku(): void
{
$john = $this->getTestUser('john@kolab.org');
$this->assertTrue($john->hasSku('mailbox'));
$this->assertTrue($john->hasSku('storage'));
$this->assertFalse($john->hasSku('beta'));
$this->assertFalse($john->hasSku('unknown'));
$this->assertSame(0, $john->countEntitlementsBySku('unknown'));
$this->assertSame(0, $john->countEntitlementsBySku('2fa'));
$this->assertSame(1, $john->countEntitlementsBySku('mailbox'));
$this->assertSame(5, $john->countEntitlementsBySku('storage'));
}
/**
* Test User::name()
*/
public function testName(): void
{
Queue::fake();
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$this->assertSame('', $user->name());
$this->assertSame($user->tenant->title . ' User', $user->name(true));
$user->setSetting('first_name', 'First');
$this->assertSame('First', $user->name());
$this->assertSame('First', $user->name(true));
$user->setSetting('last_name', 'Last');
$this->assertSame('First Last', $user->name());
$this->assertSame('First Last', $user->name(true));
}
/**
* Test resources() method
*/
public function testResources(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$resources = $john->resources()->orderBy('email')->get();
$this->assertSame(2, $resources->count());
$this->assertSame('resource-test1@kolab.org', $resources[0]->email);
$this->assertSame('resource-test2@kolab.org', $resources[1]->email);
$resources = $ned->resources()->orderBy('email')->get();
$this->assertSame(2, $resources->count());
$this->assertSame('resource-test1@kolab.org', $resources[0]->email);
$this->assertSame('resource-test2@kolab.org', $resources[1]->email);
$resources = $jack->resources()->get();
$this->assertSame(0, $resources->count());
}
/**
* Test sharedFolders() method
*/
public function testSharedFolders(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$folders = $john->sharedFolders()->orderBy('email')->get();
$this->assertSame(2, $folders->count());
$this->assertSame('folder-contact@kolab.org', $folders[0]->email);
$this->assertSame('folder-event@kolab.org', $folders[1]->email);
$folders = $ned->sharedFolders()->orderBy('email')->get();
$this->assertSame(2, $folders->count());
$this->assertSame('folder-contact@kolab.org', $folders[0]->email);
$this->assertSame('folder-event@kolab.org', $folders[1]->email);
$folders = $jack->sharedFolders()->get();
$this->assertSame(0, $folders->count());
}
/**
* Test user restoring
*/
public function testRestore(): void
{
Queue::fake();
// Test an account with users and domain
$userA = $this->getTestUser('UserAccountA@UserAccount.com', [
'status' => User::STATUS_LDAP_READY | User::STATUS_IMAP_READY | User::STATUS_SUSPENDED,
]);
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$domainA = $this->getTestDomain('UserAccount.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$domainB = $this->getTestDomain('UserAccountAdd.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$userA->assignPackage($package_kolab);
$domainA->assignPackage($package_domain, $userA);
$domainB->assignPackage($package_domain, $userA);
$userA->assignPackage($package_kolab, $userB);
$storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first();
$now = \Carbon\Carbon::now();
$wallet_id = $userA->wallets->first()->id;
// add an extra storage entitlement
$ent1 = \App\Entitlement::create([
'wallet_id' => $wallet_id,
'sku_id' => $storage_sku->id,
'cost' => 0,
'entitleable_id' => $userA->id,
'entitleable_type' => User::class,
]);
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
$entitlementsDomain = \App\Entitlement::where('entitleable_id', $domainA->id);
// First delete the user
$userA->delete();
$this->assertSame(0, $entitlementsA->count());
$this->assertSame(0, $entitlementsB->count());
$this->assertSame(0, $entitlementsDomain->count());
$this->assertTrue($userA->fresh()->trashed());
$this->assertTrue($userB->fresh()->trashed());
$this->assertTrue($domainA->fresh()->trashed());
$this->assertTrue($domainB->fresh()->trashed());
$this->assertFalse($userA->isDeleted());
$this->assertFalse($userB->isDeleted());
$this->assertFalse($domainA->isDeleted());
// Backdate one storage entitlement (it's not expected to be restored)
\App\Entitlement::withTrashed()->where('id', $ent1->id)
->update(['deleted_at' => $now->copy()->subMinutes(2)]);
// Backdate entitlements to assert that they were restored with proper updated_at timestamp
\App\Entitlement::withTrashed()->where('wallet_id', $wallet_id)
->update(['updated_at' => $now->subMinutes(10)]);
Queue::fake();
// Then restore it
$userA->restore();
$userA->refresh();
$this->assertFalse($userA->trashed());
$this->assertFalse($userA->isDeleted());
$this->assertFalse($userA->isSuspended());
$this->assertFalse($userA->isLdapReady());
$this->assertFalse($userA->isImapReady());
$this->assertTrue($userA->isActive());
$this->assertTrue($userB->fresh()->trashed());
$this->assertTrue($domainB->fresh()->trashed());
$this->assertFalse($domainA->fresh()->trashed());
// Assert entitlements
$this->assertSame(7, $entitlementsA->count()); // mailbox + groupware + 5 x storage
$this->assertTrue($ent1->fresh()->trashed());
$entitlementsA->get()->each(function ($ent) {
$this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5)));
});
// We expect only CreateJob + UpdateJob pair for both user and domain.
// Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method
// is implemented we cannot skip the UpdateJob in any way.
// I don't want to overwrite this method, the extra job shouldn't do any harm.
$this->assertCount(4, Queue::pushedJobs()); // @phpstan-ignore-line
Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1);
Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1);
Queue::assertPushed(
\App\Jobs\User\CreateJob::class,
function ($job) use ($userA) {
return $userA->id === TestCase::getObjectProperty($job, 'userId');
}
);
}
/**
* Test user account restrict() and unrestrict()
*/
public function testRestrictAndUnrestrict(): void
{
Queue::fake();
// Test an account with users, domain
$user = $this->getTestUser('UserAccountA@UserAccount.com');
$this->assertFalse($user->isRestricted());
$user->restrict();
$this->assertTrue($user->fresh()->isRestricted());
Queue::assertPushed(
\App\Jobs\User\UpdateJob::class,
function ($job) use ($user) {
return TestCase::getObjectProperty($job, 'userId') == $user->id;
- });
+ }
+ );
Queue::fake(); // reset queue state
$user->refresh();
$user->unrestrict();
$this->assertFalse($user->fresh()->isRestricted());
Queue::assertPushed(
\App\Jobs\User\UpdateJob::class,
function ($job) use ($user) {
return TestCase::getObjectProperty($job, 'userId') == $user->id;
- });
+ }
+ );
}
/**
* Tests for AliasesTrait::setAliases()
*/
public function testSetAliases(): void
{
Queue::fake();
Queue::assertNothingPushed();
$user = $this->getTestUser('UserAccountA@UserAccount.com');
$domain = $this->getTestDomain('UserAccount.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$this->assertCount(0, $user->aliases->all());
$user->tenant->setSetting('pgp.enable', 1);
// Add an alias
$user->setAliases(['UserAlias1@UserAccount.com']);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1);
$user->tenant->setSetting('pgp.enable', 0);
$aliases = $user->aliases()->get();
$this->assertCount(1, $aliases);
$this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']);
// Add another alias
$user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2);
Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1);
$aliases = $user->aliases()->orderBy('alias')->get();
$this->assertCount(2, $aliases);
$this->assertSame('useralias1@useraccount.com', $aliases[0]->alias);
$this->assertSame('useralias2@useraccount.com', $aliases[1]->alias);
$user->tenant->setSetting('pgp.enable', 1);
// Remove an alias
$user->setAliases(['UserAlias1@UserAccount.com']);
$user->tenant->setSetting('pgp.enable', 0);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3);
Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1);
Queue::assertPushed(
\App\Jobs\PGP\KeyDeleteJob::class,
function ($job) use ($user) {
$userId = TestCase::getObjectProperty($job, 'userId');
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
return $userId == $user->id && $userEmail === 'useralias2@useraccount.com';
}
);
$aliases = $user->aliases()->get();
$this->assertCount(1, $aliases);
$this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']);
// Remove all aliases
$user->setAliases([]);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 4);
$this->assertCount(0, $user->aliases()->get());
}
/**
* Tests for UserSettingsTrait::setSettings() and getSetting() and getSettings()
*/
public function testUserSettings(): void
{
Queue::fake();
Queue::assertNothingPushed();
$user = $this->getTestUser('UserAccountA@UserAccount.com');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0);
// Test default settings
// Note: Technicly this tests UserObserver::created() behavior
$all_settings = $user->settings()->orderBy('key')->get();
$this->assertCount(2, $all_settings);
$this->assertSame('country', $all_settings[0]->key);
$this->assertSame('CH', $all_settings[0]->value);
$this->assertSame('currency', $all_settings[1]->key);
$this->assertSame('CHF', $all_settings[1]->value);
// Add a setting
$user->setSetting('first_name', 'Firstname');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame('Firstname', $user->getSetting('first_name'));
$this->assertSame('Firstname', $user->fresh()->getSetting('first_name'));
// Update a setting
$user->setSetting('first_name', 'Firstname1');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame('Firstname1', $user->getSetting('first_name'));
$this->assertSame('Firstname1', $user->fresh()->getSetting('first_name'));
// Delete a setting (null)
$user->setSetting('first_name', null);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame(null, $user->getSetting('first_name'));
$this->assertSame(null, $user->fresh()->getSetting('first_name'));
// Delete a setting (empty string)
$user->setSetting('first_name', 'Firstname1');
$user->setSetting('first_name', '');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 5);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame(null, $user->getSetting('first_name'));
$this->assertSame(null, $user->fresh()->getSetting('first_name'));
// Set multiple settings at once
$user->setSettings([
'first_name' => 'Firstname2',
'last_name' => 'Lastname2',
'country' => null,
]);
// TODO: This really should create a single UserUpdate job, not 3
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 7);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame('Firstname2', $user->getSetting('first_name'));
$this->assertSame('Firstname2', $user->fresh()->getSetting('first_name'));
$this->assertSame('Lastname2', $user->getSetting('last_name'));
$this->assertSame('Lastname2', $user->fresh()->getSetting('last_name'));
$this->assertSame(null, $user->getSetting('country'));
$this->assertSame(null, $user->fresh()->getSetting('country'));
$all_settings = $user->settings()->orderBy('key')->get();
$this->assertCount(3, $all_settings);
// Test getSettings() method
$this->assertSame(
[
'first_name' => 'Firstname2',
'last_name' => 'Lastname2',
'unknown' => null,
],
$user->getSettings(['first_name', 'last_name', 'unknown'])
);
}
/**
* Tests for User::users()
*/
public function testUsers(): void
{
$jack = $this->getTestUser('jack@kolab.org');
$joe = $this->getTestUser('joe@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$wallet = $john->wallets()->first();
$users = $john->users()->orderBy('email')->get();
$this->assertCount(4, $users);
$this->assertEquals($jack->id, $users[0]->id);
$this->assertEquals($joe->id, $users[1]->id);
$this->assertEquals($john->id, $users[2]->id);
$this->assertEquals($ned->id, $users[3]->id);
$users = $jack->users()->orderBy('email')->get();
$this->assertCount(0, $users);
$users = $ned->users()->orderBy('email')->get();
$this->assertCount(4, $users);
}
/**
* Tests for User::walletOwner() (from EntitleableTrait)
*/
public function testWalletOwner(): void
{
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$this->assertSame($john->id, $john->walletOwner()->id);
$this->assertSame($john->id, $jack->walletOwner()->id);
$this->assertSame($john->id, $ned->walletOwner()->id);
// User with no entitlements
$user = $this->getTestUser('UserAccountA@UserAccount.com');
$this->assertSame($user->id, $user->walletOwner()->id);
}
/**
* Tests for User::wallets()
*/
public function testWallets(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$this->assertSame(1, $john->wallets()->count());
$this->assertCount(1, $john->wallets);
$this->assertInstanceOf(\App\Wallet::class, $john->wallets->first());
$this->assertSame(1, $ned->wallets()->count());
$this->assertCount(1, $ned->wallets);
$this->assertInstanceOf(\App\Wallet::class, $ned->wallets->first());
}
}
diff --git a/src/tests/Unit/Backends/DAV/FolderTest.php b/src/tests/Unit/Backends/DAV/FolderTest.php
index 5d3291f9..be287205 100644
--- a/src/tests/Unit/Backends/DAV/FolderTest.php
+++ b/src/tests/Unit/Backends/DAV/FolderTest.php
@@ -1,53 +1,57 @@
<?php
namespace Tests\Unit\Backends\DAV;
use App\Backends\DAV\Folder;
use Tests\TestCase;
class FolderTest extends TestCase
{
/**
* Test Folder::fromDomElement()
*/
public function testFromDomElement(): void
{
$xml = <<<XML
<?xml version="1.0" encoding="utf-8"?>
-<d:multistatus xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/" xmlns:k="Kolab:">
+ <d:multistatus xmlns:d="DAV:"
+ xmlns:cs="http://calendarserver.org/ns/"
+ xmlns:c="urn:ietf:params:xml:ns:caldav"
+ xmlns:a="http://apple.com/ns/ical/"
+ xmlns:k="Kolab:">
<d:response>
<d:href>/dav/calendars/user/alec@aphy.io/Default/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype>
<d:collection/>
<c:calendar/>
</d:resourcetype>
<d:displayname><![CDATA[personal]]></d:displayname>
<cs:getctag>1665578572-16</cs:getctag>
<c:supported-calendar-component-set>
<c:comp name="VEVENT"/>
<c:comp name="VTODO"/>
<c:comp name="VJOURNAL"/>
</c:supported-calendar-component-set>
<a:calendar-color>#cccccc</a:calendar-color>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>
XML;
$doc = new \DOMDocument('1.0', 'UTF-8');
$doc->loadXML($xml);
$folder = Folder::fromDomElement($doc->getElementsByTagName('response')->item(0));
$this->assertInstanceOf(Folder::class, $folder);
$this->assertSame("/dav/calendars/user/alec@aphy.io/Default/", $folder->href);
$this->assertSame('1665578572-16', $folder->ctag);
$this->assertSame('personal', $folder->name);
$this->assertSame('cccccc', $folder->color);
$this->assertSame(['collection', 'calendar'], $folder->types);
$this->assertSame(['VEVENT', 'VTODO', 'VJOURNAL'], $folder->components);
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Thu, Mar 19, 8:52 AM (23 h, 35 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
457605
Default Alt Text
(118 KB)
Attached To
Mode
R2 kolab
Attached
Detach File
Event Timeline
Log In to Comment