Page MenuHomePhorge

No OneTemporary

Size
118 KB
Referenced Files
None
Subscribers
None
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

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)

Event Timeline